Tracking APT28 PixyNetLoader: Evolutions from 2024 to 2026

Key takeaways

In this article, we will examine the evolutions of the APT28 PixyNetLoader code family, and how, by analyzing approximately 90 samples and studying the shared code between them, we can identify 4 major different sub-families that we will briefly detail, and produce a single YARA rule to match all of these codes.

We will then focus on the latest family to date and cover the most recent evolutions of this malware’s steganographic loading mode, which appeared in March 2026. We will detail the evolutions of this mode and provide a Python code snippet enabling the extraction and decryption of a payload.

We will also see how to detect these codes and how to potentially track their future evolutions on various platforms.

Finally, we will provide all relevant IOCs in the appendix.

PixyNetLoader

This threat fits a relatively standard compromise scheme through vulnerability exploitation via a malicious .DOC file (CVE-2026-21509 in February 2026) executing a version of the SimpleDropper code, which in turn drops a PixyNetLoader DLL installed via COM persistence and a PNG file. PixyNetLoader loads the .PNG file, extracts a Covenant Grunt payload from the pixels’ LSBs, using the filen service for communication.

PixyNetLoader infection chain

We chose to cover the PixyNetLoader code because it is the most likely to be detected on a network since it is installed with persistence and is not encrypted. Furthermore, an article from CERT-UA in February 2026 shows that it is still current. This code was notably covered in the articles OPERATION PHANTOM NET VOXEL by Sekoia and OPERATION NEUSPLOIT by ZSCALER.

Clustering

Let’s take a first sample, 52b6fb40e7efb09c2bebe8550178e7e30009600bdedd1acae085d753761b7598 , referenced in the February Cert UA article.

This is a PixyNetLoader; the DLL will be installed via COM persistence and will perform steganography to extract a shellcode from the PNG data in its companion file ( %programdata%\Microsoft OneDrive\setup\Cache\SplashScreen.png ), and which exports itself as EhStorShell.dll. The payload is contained in the least significant bits of the file’s pixels.

A similarity search immediately reveals 2 other samples:

Similar samples search result on our Exalyze platform

These are similar codes, slightly modified but based on the same source.

A search by function similarities (i.e., a function-by-function comparison with all binaries on Exalyze) brings up 87 other samples that match between 4 and 45 similar functions.

Similar functions search result for our sample

We have listed a total of 23 different versions of PixyNetLoader, ranging from 2024-04-12 to 2026-04-15. We group each version by RICH header hash and compilation timestamp. These 23 versions cover a total of about a hundred samples (we deliberately exclude approximately 430 samples of the 58a6e3e4 family, see below). 1 Among these 23 versions, we can find 4 different families, by cross-referencing the similar functions in each:

Note that some samples embed the PNG file directly as a resource.

Some codes of the C family are barely detected, such as a5729b6e36c0ab4798db5004700a1fe843f4d1b0811023c47b7b2972befb6360 which only 2 engines detect.

Details in terms of configurations (companion file, DLL export name, etc.) are provided in the appendix.

Families evolution

Some PixyNetLoader code strains are “hybrid” and possess several striking elements from earlier and later versions. We therefore simply repeated the process of binary diffing on Exalyze, create new rules to obtain more samples, and so on. It was also quite simple to reuse certain unique version info to identify other slightly different codes (or even belonging to other APT28 code families).

Writing rules targeting these similarities is interesting because it allows for better categorization of samples by internal functioning. It is by using function comparisons and defining YARA rules on common functions that we were able to establish larger families. For example:

For example, analyzing the PNG library of strain A makes it quite easy to write a YARA rule.

PNG header parser

And the YARA rule:

private rule DllCom {
    strings:
        $com_01 = "DllRegisterServer"
        $com_02 = "DllCanUnloadNow"
        $com_03 = "DllGetClassObject"
        $str_01 = "GetProcAddress"
        $str_02 = "GetCurrentProcess"
        $str_03 = "CreateMutexW"
        $str_04 = "LoadLibraryW"
    condition:
        uint16be(0) == 0x4d5a and 2 of ($com_*) and 2 of ($str_*) and filesize < 800KB
}
rule PixyNetLoader_B_PNG {
    meta:
        author = "Exatrack"
        date =   "2026-05-25"
        description = "PixyNetLoader PNG functions"
        score =   90
        tlp =  "GREEN"
        sample_hash = "52b6fb40e7efb09c2bebe8550178e7e30009600bdedd1acae085d753761b7598"
    strings:
        $png_search_idat = { 80 7B 04 49 75 ?? 80 7B 05 44 75 ?? 80 7B 06 41 75 ?? 80 7B 07 54 75 ?? }
        $png_search_tIME = { 80 7B 04 74 75 ?? 80 7B 05 49 75 ?? 80 7B 06 4D 75 ?? 80 7B 07 45 75 ?? }
        $crc32_1 = { 0F B6 11 33 ?? C1 E2 08 44 8B ?? 0B D0 0F B6 41 02 C1 E2 08 0B D0 }
        $crc32_2 = { 8B C1 48 33 D0 C1 E9 08 44 0F B6 C2 49 FF C1 }
    condition:
        DllCom and all of ($png_*) and all of ($crc32_*)
}

Writing a single and simple yara rule

With Exalyze matching functions feature, it is possible to isolate 4 functions that are found in the vast majority of samples:

Strings crypt functions diff

These are functions responsible for string encryption, found in almost all samples, regardless of the binary strain, sometimes with minor variations. We can therefore create a very basic but functional yara rule:

rule PixyNetLoaderCrypto {
    meta:
        author = "Exatrack"
        date =   "2026-05-25"
        description = "PixyNetLoader crypto strings functions"
        score =   90
        tlp =  "GREEN"
        sample_hash = "52b6fb40e7efb09c2bebe8550178e7e30009600bdedd1acae085d753761b7598"
    strings:
        $pattern1_01 = { 0FB6??243841B?010000008BC?418BC?D2E0B908000000 }
        $pattern1_02 = { 410FB6C34503C02407B9070000002AC8498BC348C1E80349FFC3 }

        $pattern2_01 = { 4488024C8BC30FB657023217 }
        $pattern2_02 = { 418850014C8BC30FB65703321748837B180F }

        $pattern3_01 = { 0FB6C8D2EA4132D0F6C201 }
        $pattern3_02 = { D2EA410FB6C0D0E832D0F6C201 }
        $pattern3_03 = { 0FB6C8D2EA410FB6C0C0E80332D0F6C201 }
    condition:
        (all of ($pattern1_*) or all of ($pattern2_*) or all of ($pattern3_*)) and DllCom
}

YARA results on the Exalyze platform can be found here: https://exalyze.io/sample/search?q=yara%3A%22PixyNetLoader_CryptoAllVersions%22&submit=

PixyNetLoader Family C

Let’s focus on the last family of these samples, as it the newer, less detected one, and as it introduces a new steganography mode.

We have seen this strain packaged in malicious .xls files: https://www.virustotal.com/gui/file/87a962c6599176e1806c0ccd1b157d3f80e3ccc288c288d039872d9683da24d9/behavior

Decoy XLS document

The file leads to the dropping of a PixyNetLoader and its companion:

These resources can be extracted quite easily from the raw file.

This new version radically changes the loading of PNG files:

Payload extraction and decrypt routine

We were able to create a python script for payload extraction and decryption available in the Appendix A below.

Script decrypting payload result

For the moment, all Family C versions use the LSB extraction mode 0. Modes 1 and 2 are supported but not used. Their main difference consists of the number of least significant bits extracted per pixel.

It should also be noted that the attacker uses versioning in their PNG files; interestingly, the date indicated ca_distr_15.04.exe.shellcode is generally roughly the same day (more or less, several have 1 day of delta) as the compilation timestamp of the PixyNetLoader binary that loads it.

The payload remains a Covenant Grunt malware (VersionInfo Publish.exe) using FILEN as Cloud C2, prefixed by a shellcode allowing it to be loaded.

Detection

Detection of these codes is relatively trivial if proper tracking is performed. The companion .png files do not vary that much, and it is possible to search for them on the network. The COM registry keys used for persistence are also relatively few. Finally, we were able to show that effective YARA rules covering the majority of these codes are also quite easy to write.

Monitoring new codes

By taking the global characteristics of these samples and looking for those that group them together:

It is possible to define Exalyze searches that cross-reference a number of them across all versions, without even using the code structure itself and focusing only on file gloabal profiles. For example:

The second, slightly broader search, brings up other samples. After isolating those clearly not linked to APT28, we find 2 APT28 samples that are not PixyNetLoader:

We therefore see that this player is keeping certain code packaging habits, which can, in addition to bouncing from one to another, potentially make it possible to identify new families. However, it should be kept in mind that these habits are not unique to APT28 and may also be present in other players.

Conclusion

We have seen that it was relatively easy to track these codes and identify common points allowing the creation of effective YARA rules (the analysis from start to finish took us about 6 days of work). Unsurprisingly, these are mainly reused libraries or cryptographic routines.

We also saw that the latest iteration of the code introduces a new payload extraction mode, which is more robust (separate keys) and appears set to evolve: 2 of the LSB extraction modes are currently unused.

It was also possible to track certain strains simply by searching for the unique version info used by the attacker or the rich hashes.

The attacker generates new versions relatively often, and the files are used very quickly after generation, given the delay we observed between code generation and the actual attack.

Appendix A: PNG payload extraction script

from PIL import Image
import sys
import hashlib, struct
import zlib
from Crypto.Cipher import AES

# WARNING : MODE 1 AND 2 have not seen in the wild yet
def extract_lsb(
    image_path: str,
    mode: int,
    byte_offset: int,
    count: int
) -> bytes:
    img = Image.open(image_path).convert("RGBA")
    pixels = bytes(img.tobytes())
    base = byte_offset  
    out = bytearray()
 
    if mode != 0:
        raise ValueError("Only '0' LSB mode is supported for now")
       
    if mode == 0:
        for i in range(count):
            o = base + i * 8
            p = pixels[o:o+8]
            r8 =  (p[7] & 1)
            r8 = ((r8 << 1) | (p[6] & 1)) & 0xFF
            r8 = ((r8 << 1) | (p[5] & 1)) & 0xFF
            r8 = ((r8 << 1) | (p[4] & 1)) & 0xFF
            r8 = ((r8 << 1) | (p[3] & 1)) & 0xFF
            r8 = ((r8 << 1) | (p[2] & 1)) & 0xFF
            r8 = ((r8 << 1) | (p[1] & 1)) & 0xFF
            r8 = ((r8 << 1) | (p[0] & 1)) & 0xFF
            out.append(r8)
 
    return bytes(out)

def derive_primary_key(initkey) -> bytes:
    raw_digest = hashlib.sha256(initkey).digest()
    # byteswap
    words = struct.unpack(">8I", raw_digest)    
    key = struct.pack(">8L", *words)          
    return  key

def decrypt(image_path, aeskey):
    salt = extract_lsb(image_path, mode=0, byte_offset=0x0, count=0x10)
    iv = extract_lsb(image_path, mode=0, byte_offset=0x80, count=0x10)
    datadec = extract_lsb(image_path, mode=0, byte_offset=0x100, count=0x40)
    nkey = derive_primary_key(aeskey)
    derivatedkey = hashlib.pbkdf2_hmac("sha256", nkey, salt, 20000, dklen=32)
    cipher = AES.new(derivatedkey, AES.MODE_CBC, iv)
    clearheader = cipher.decrypt(datadec)

    print("---- HEADER ----")
    print(f"ORIGINAL KEY: {aeskey.hex()}")
    print(f"KEY SHA2+SWAP: {nkey.hex()}")
    print(f"SALT: {salt.hex()}")
    print(f"IV: {iv.hex()}")
    print(f"FINAL AESKEY: {derivatedkey.hex()}")
    print(f"DATA: {datadec.hex()}")

    if clearheader[:4] != b"HIDE":
        print("!!! Invalid header, bad magic")
        printable = ''.join(
        chr(b) if 32 <= b <= 126 else '.'
        for b in clearheader
        )
        print(clearheader.hex())
        print(printable)
        return

    magic = clearheader[:4]
    vers,pixel_offset,payload_size,checksum = struct.unpack("<4L", clearheader[4:0x14])
    name = clearheader[0x14:].rstrip(b"\x00")
    print("---- HIDE HEADER ----")
    print(f"MAGIC: {magic}")
    print(f"VERSION: {vers}")
    print(f"PAYLOAD PIXEL OFFSET: {hex(pixel_offset)}")
    print(f"PAYLOAD SIZE: {hex(payload_size)}")
    print(f"CHECKSUM: {hex(checksum)}")
    print(f"INTERNAL NAME: {name}")

    payload_encrypted = extract_lsb(image_path, mode=0, byte_offset=pixel_offset, count=payload_size)
    payload_decrypted = cipher.decrypt(payload_encrypted)
    payload_decrypted = payload_decrypted[:-payload_decrypted[-1]]
    crc = zlib.crc32(payload_decrypted)

    print("---- PAYLOAD ----")
    print(f"ENCRYPTED FIRST BYTES: {payload_encrypted.hex()[:80]}")
    print(f"ENCRYPTED LAST BYTES: {payload_encrypted.hex()[-80:]}")
    print(f"CLEAR FIRST BYTES: {payload_decrypted.hex()[:80]}")
    print(f"CLEAR LAST BYTES: {payload_decrypted.hex()[-80:]}")
    print(f"CLEAR CRC32: {hex(crc)}")

    if crc != checksum:
        print("!!! Invalid data, bad checksum")
        return
   
    open("payload.bin", "wb").write(payload_decrypted)
    print("Payload is correct, has been written to <payload.bin> file")


if __name__ == "__main__":

    if len(sys.argv) < 3:
        print(f"Usage: python {sys.argv[0]} image.png aeskey")
        sys.exit(1)

    image_path = sys.argv[1]
    aeskey = sys.argv[2].encode()
    decrypt(image_path, aeskey)

APPENDIX B: families

FAMILY A

2024-12-04

2025-04-02 (Operation Net Voxel)

2025-06-11 (Operation Net Voxel)

2025-07-17 (Operation Phantom Net Voxel)

2025-08-26

2026-01-21 (Operation Neusploit)

2026-01-23a (Operation Neusploit)

FAMILY 58a6e3e4

2025-09-04

FAMILY B

2025-09-10

2025-09-18a

2025-09-18b

2025-09-29

2025-10-14

2026-01-16

2026-02-10

2026-02-17

2026-02-25

2026-03-02

2026-03-10

FAMILY C

2026-03-13

2026-03-19

2026-04-10

2026-04-15

Appendix C: IOCS

PNG files paths

PNG files paths

%LocalAppData%\windows.png
%programdata%\Microsoft OneDrive\setup\Cache\SplashScreen.png
%programdata%\Microsoft\DeviceSync\EdgeSync\EdgeLogo.png
%programdata%\Microsoft\DeviceSync\EdgeSync\start.png
%public%\pictures\WordIllustration.png
%programdata%\Microsoft\DeviceSync\IID11f5d-97b9-44be-812f-4fd5fd0d6d84\Sample.png
%programdata%\Microsoft\DeviceSync\8acd6e71-bf10-4800-aeee-7de00edc9781\background.png
%programdata%\Microsoft\DeviceSync\UIDD304d-22c6-4f29-801b-58d0685fe77b\Default.png

COM persistance

CLSID registry keys used (non exhaustive)

\Classes\CLSID\{68DDBB56-9D1D-4FD9-89C5-C0DA2A625392}\InProcServer32\
\Classes\CLSID\{D9144DCD-E998-4ECA-AB6A-DCD83CCBA16D}\InProcServer32\
\Classes\CLSID\{2227A280-3AEA-1069-A2DE-08002B30309D}\InProcServer32\ 

DLL files paths

Installation paths (non exhaustive)

%APPDATA%\microsoft\protect\altio32.dll
%ALLUSERPROFILE%\usopublic\data\user\ehstoreshell.dll
C:\ProgramData\prnfldr.dll
C:\ProgramData\Microsoft OneDrive Storage\MimeTypes\Default\mimeobj.dll
C:\ProgramData\USOShared\Logs\User\svrobj.dll
C:\ProgramData\USOShared\Logs\User\adwapi32.dll
C:\ProgramData\USOShared\Logs\User\FlightConfig.dll
C:\ProgramData\Microsoft\DeviceSync\EdgeSyncPr.dll

Exploits

Several exploits (DOC, XLS), non exhaustive list

580cd001739f95c343c0b3f16ab2c274b54d126e1c35eaf3b7377add37435f22 drops ab681611
0773a145bff130fa527ecbf80400a6d630fa5ec0a53f6a252a7cf62fb63cf8d5 drops 57184dae
3e63952cdae714bcbdbf9a264e122c264f1f3e66d68cc6702ed1a8389153bb5c drops 57184dae
c752290553f23dc6dc3d0e78581b7a20c78571092895129513d0c195f6de360f drops 61b74807
f066bcbc45151fdfe2f2921dd7cb4a09ed583f514ca62960263623365465553f drops 61b74807
8c1dc9732884c6078b23953b78314a8d0d8b8d9fe42e5f97a7cd09b8ace943a9 drops 52b6fb40
5c2a2c49e200a2d048f477440da75ff4a99c676943f6f7cac1ce70190520f998 drops 7acb7ed2 
0003699a517af0a969058d3ad971704c11bb8bca2cb79994fe55cfbe6425fb68 drops 7bb241f8
7cfab5f53bbbd05c3d123393f2a6b41ff3cd46821b902d0b3eddd65b6476a99d drops 7bb241f8
8f049b3a100747167eb87fb3a134e446d9057f179b4f334a5a4006369605095a drops ce5a0cb1
57253f322504e0a8256d46f31c19e228b8c55a14ee18e759936c71941c8ee4ad drops 88e28107
17c697082bb95d05d5e761ca4a9cfdd5ff10ff1a547a9639991924e8448f4d54 drops e5a4f511