David Rosas — research
Oct 10, 2026·AgentTesla·Reverse engineering

Five stages, a PNG and a plaintext password.

A 604 KB dropper hides an infostealer behind ConfuserEx, steganography in an image’s pixels, .NET Reactor and two hand-rolled XOR layers. Five stages down to the payload, which exfiltrates over SMTP with the credentials embedded in the binary.

Sample ffdc0c9d0453f714bfc0b1d98141c21d Compiled 17 Jul 2021 Family AgentTesla Target Windows · PE32 .NET

The summary and the C2

The sample is a PE32 .NET binary, not native code: everything that follows happens in IL and CLR metadata, not in x86 instructions. It hides an AgentTesla across five stages, and each hop uses a different technique: ConfuserEx LZMA constants, a hand-rolled reversed hexadecimal encoding, steganography in a PNG’s pixels, .NET Reactor with control-flow flattening, QuickLZ and a positional XOR.

The final payload exfiltrates over SMTP, with the credentials sitting inside the binary. It harvests passwords from 26 Chromium browsers and 39 further applications, logs keystrokes, watches the clipboard and captures the screen.

Command and control configuration

SMTP server
mail.scottbyscott.com
Port
587  EnableSsl = false
Username
ideshow@eflownutrition.com
Password
ngo•••••• masked
Recipient
harrisonjeff401@gmail.com

The password is masked deliberately. eflownutrition.com appears to be a legitimate company whose mailbox is being used as a relay, so publishing the full credential would expose that account to anyone reading this. The rest are ordinary indicators.

That choice of channel is not incidental: SMTP dominates in this family precisely because the operator only needs one compromised mailbox. No infrastructure of their own, no domain, no server to maintain.

The five-stage chain

0 · CorrectionAlgorit.exe VB.NET dropper · 604 KB ConfuserEx: 11,040 u32, XOR with 16 xorshift keys → LZMA → reversed hex → base64 2 · TimeSpan.dll loader · 69,632 B Stego: 354×354 PNG, BGRA pixels XOR seed ^0x70 + key “pHw” 3 · SinkProvider.dll .NET Reactor stub · 497,664 B QuickLZ (QCLZ magic) 4 · resource container 524 B metadata, no code · 231,424 B XOR BE16(“OpWegUbUUQ”) + + 16 B self-keyed XOR 5 · AgentTesla stealer · 221,696 B
Each hop uses a different technique. Stages 0 and 3 are the only ones protected by a commercial obfuscator; every other transformation is the crypter author’s own. Stage 4 holds no code at all: it exists only to carry resources that a static look at stage 3 would never find.

Dropper — CorrectionAlgorit.exe

VB.NET protected with ConfuserEx. It poses as a school project called “SpellingBee”, with 13 working WinForms forms. Ten classes whose names are made of invisible Unicode characters implement the LZMA range decoder.

Internal paths give away the origin of the reused project: \\file-home1\TomOye161$\Computing\Year 2\Spelling Bee\. Someone stole a student’s coursework to use as a wrapper.

It fires from Admin_page::InitializeComponent, when the form is constructed.

604 KB · md5 ffdc0c9d0453f714bfc0b1d98141c21d

Loader — TimeSpan.dll

Buried among classes from the open-source WinForms theme “Pico2012”. It sleeps for 39 to 61 seconds before acting, to outlast sandbox analysis windows.

Signature evasion by string splitting: "Pico2012".Replace("Pico","Inv").Replace("2012","oke") yields "Invoke" at runtime, so the name never appears in the binary.

69,632 B · md5 ecff0ff8f308ad9a7ab4e8d8c6632f1f

Stub — SinkProvider.dll

Protected with .NET Reactor. It carries anti-VM, anti-Wine, Defender evasion, persistence and the injector. It is not the stealer: it is the crypter’s wrapper.

Camouflaged with the FastColoredTextBox library, whose T-SQL and HTML regexes make up most of its 237 encrypted strings.

497,664 B · md5 56f0eb08635393c5052221a18b8e4488

Resource container

A .NET assembly with 524 bytes of metadata and not a single method: it exists purely to hold resources. The assembly name is a GUID.

The trick: the previous stage’s resources are moved into a separate assembly loaded in memory with Assembly.Load, so a static look at the stub never finds them. One of them is called SinkProvider.g.resources, which gives the relationship away.

231,424 B · md5 f3553fc66fa0655005350c4e91e447b5

AgentTesla payload

An executable with an entry point, 84 types and 1,347 methods. It references System.Management, System.Security and System.Xml.

Everything lives here: credentials, keylogger, clipboard, screenshots and exfiltration.

221,696 B · md5 7a5999bf2197cd4e208e32e67e04bead

The obfuscation layers

ConfuserEx constants

The dropper keeps its strings in an embedded array of 11,040 32-bit integers. An xorshift PRNG seeded with -794857484 produces 16 keys applied in blocks, each evolving after use. The result is LZMA-decompressed.

x ^= x >>> 12;  x ^= x << 25;  x ^= x >>> 27     // 16 keys

for (i = 0; i < 11040; i += 16) {
    for (j = 0; j < 16; j++) {
        v = data[i+j] ^ key[j];
        write_le32(out, v);
        key[j] ^= v;                              // the key evolves
    }
}
out = LZMA_decompress(out)                        // 215,920 B of strings

An invented hexadecimal encoding

Inside those constants sits a 212,461-character string drawn from an alphabet of just 19 symbols: the 16 hexadecimal digits plus space, hyphen and <. It is hexadecimal read backwards, space-separated, where the two extra symbols compress runs of null bytes.

ElementMeaningExample
The whole stringReversed, with StrReverse…15 17 65 45 → 54 56 71 51
SpaceSeparator between hex pairs54 56 71 51 = "TVqQ"
-One base64 A, i.e. a null byte--4D → AAM
<Three base64 As<<<51 → AAAAAAAAAQ

The mapping checks out against the canonical DOS header of a .NET binary, which is fixed and known: the resulting 92,844 base64 characters decode to stage 2.

Steganography in the pixels

Stage 3 travels inside a 354×354 PNG stored as a Bitmap resource under the name TaskSchedulerAwaitTaskContinuati.

1. COLUMN-major traversal 354 × 354 RGBA 2. each pixel → 4 bytes Color.ToArgb() → int32 BitConverter.GetBytes, little-endian B G R A ARGB order flips when serialised 3. the resulting stream 4 B length 497,665 B of encrypted data padding the first 4 bytes are dropped; the length comes from them 4. out[i] = data[i] ⊕ (data[last] ⊕ 0x70) ⊕ key[i mod 6] PE stage 3
The length prefix occupies the first pixel, so the payload starts at the second. The 6-byte key is "pHw" in UTF-16, and the seed comes from the last byte of the block itself: without that byte nothing decrypts.

The keystream has a period of 6: 9a ea 9a d2 9a ed. Against the known DOS header the key falls out immediately.

.NET Reactor

Stage 3 encrypts its 237 strings with AES, indexed by offset, through a decryptor of 3,517 IL instructions flattened into a state machine with some 370 targets and opaque predicates. Two helper methods turn out to be constants: one always returns 1 and the other always 0, which accounts for 61 of the branches.

The native NecroBit stubs come to about 70 bytes in total: two x86 and x64 trampolines of the mov rax, imm64; jmp rax kind. They do not protect the string decryptor.

QuickLZ, and two hand-rolled XORs

The hop to stage 4 uses a bespoke decompressor with the magic QCLZ, written with pointer arithmetic. The last hop compresses nothing: it is two chained XOR layers, verified against the real binary.

// iNi: XOR with a seed derived from the last byte + a repeating key
key = Encoding.BigEndianUnicode("OpWegUbUUQ")   // cycles over 10 characters
k1  = d[last] ^ 0x70
out[i] = d[i] ^ k1 ^ key[i % 10]                // the last byte is dropped

// ONb: self-keyed XOR -- the header is its own key
k = d[0:16]
out[i] = d[16+i] ^ k[i % 16]

// 221,713 B  ->  iNi  ->  221,712 B  ->  ONb  ->  221,696 B = AgentTesla

And here is the detail that says most about the author: iNi is the same primitive that decrypts the PNG steganography two stages earlier. Identical structure — a ^ 0x70 seed over the last byte, a key repeating over the string length — differing only in the encoding used to turn the key into bytes. It is one routine reused, and it explains why the constant 0x70 reappears across unrelated stages. As a family signature it is excellent.

What the stub does

Before dropping the payload, stage 3 checks where it is.

CategoryWhat it looks for
HypervisorsRegistry keys for VirtualBox Guest Additions and VMware Tools, DEVICEMAP\Scsi, QEMU, and via WMI SELECT * FROM Win32_VideoController against VirtualBox Graphics Adapter, VMware SVGA II and S3 Trio32/64
SandboxesSbieDll.dll from Sandboxie, plus decoy usernames: SANDBOX, VIRUS, MALWARE, SCHMIDTI, SAMPLE, C:\file.exe
WineGetProcAddress(GetModuleHandle("kernel32.dll"), "wine_get_unix_file_name") — if that export exists, you are not on Windows
Defenderpowershell Add-MpPreference -ExclusionPath on its own install path
InjectionZwUnmapViewOfSection, VirtualAllocEx, WriteProcessMemory, CreateProcessA, Get/SetThreadContext and their Wow64 variants, into MSBuild.exe, vbc.exe or RegSvcs.exe — signed .NET Framework binaries

Persistence

With the real configuration values in place, the sequence it executes is:

GetFolderPath(ApplicationData)                       -> %APPDATA%
File.Exists('%APPDATA%\GIxVTvvWqR.exe')              -> if absent:
  DirectorySecurity.SetAccessRuleProtection(...)     breaks ACL inheritance
  FileSystemSecurity.AddAccessRule(...)  x9          hardened ACL
  FileSystemInfo.set_Attributes(...)     x2          hidden / system
  File.Copy(sample.exe -> %APPDATA%\GIxVTvvWqR.exe)  self-copy
  WindowsIdentity.GetCurrent().Name                  user for the XML
  Path.GetTempFileName() + String.Replace x2         substitutes [LOCATION] and [USERID]
  File.WriteAllText(temp)                            writes the task XML
  Process.Start('schtasks.exe', '/Create /TN "Updates\..." /XML "temp"')
  File.Delete(temp)                                  deletes the XML

The XML template is not among the strings: it lives in a resource inside the stage 4 container. Its parameters say a fair amount:

ElementValueImplication
LogonTrigger[USERID]Starts at every logon
RunLevelLeastPrivilegeNo UAC prompt; works as a standard user
ExecutionTimeLimitPT0SNo execution limit
AllowHardTerminatefalseResists termination by the scheduler
HiddenfalseVisible in Task Scheduler — detection for free
Date2014-10-25Fake date, a decoy

What it steals, and how

The entry point pins TLS 1.0–1.3, builds a victim identifier from the username and machine name, and arms a 30-second timer. The static constructor installs the keyboard hook before anything else.

COLLECTORS PW · passwords 26 browsers + 39 apps KL · keystrokes WH_KEYBOARD_LL hook CO · cookies Chromium + 12 Gecko SC · screenshot full screen, JPEG HTML report header: machine, user, CPU, RAM one mail each 30 s timer active CHANNELS SMTP mail.scottbyscott.com : 587 EnableSsl = false · no TLS to harrisonjeff401@gmail.com HTTP POST over Tor body p=<data>, 3DES from .C .j HTTP→SOCKS bridge, IPAddress.Any target %PostURL% — never substituted compiled, but inactive
The four reports are sent separately, each as its own mail with its own subject tag. The alternative channel is fully implemented — including a bespoke HTTP-to-SOCKS proxy, because HttpWebRequest does not speak SOCKS — but its destination URL was left as an unsubstituted placeholder. That the live channel is SMTP matches what is documented for the family: the operator only needs one compromised mailbox.

The machinery it carries

What stands out about the payload is not what it steals, but that it depends on nothing from the system to do it.

ComponentWhat it is
SQLite readerWalks B-tree pages by hand, 1,301 instructions. No System.Data.SQLite: it needs no native DLL and sidesteps the most obvious signature of a browser credential stealer
DER parser895 instructions of recursive ASN.1 TLV decoding, without a single external API
Berkeley DBFor key3.db, the format Firefox used before key4.db
AES-GCMVia BCrypt, for modern Chromium’s v10/v11 values
3DES via reflectionSets Key, Mode, IV and Padding through LateSet, by name. The IL never statically references the cryptography APIs: they do not appear in the binary’s reference table
keyloggerWH_KEYBOARD_LL: captures Alt and system keys too, along with the foreground window title
clipboardThe classic viewer chain, with SetClipboardViewer
HTTP→SOCKS proxyBecause HttpWebRequest does not speak SOCKS and the Tor channel needs it

That reflection-based 3DES explains some otherwise stray strings — Key, Mode, IV, Padding — sitting alone in the string table. They are not leftovers: they are the names through which it calls cryptography without declaring it.

The two decryption paths

CHROMIUM — branches on the value prefix Local State encrypted_key → DPAPI key Login Data own SQLite (.h) password_value v10/v11 no prefix BCrypt AES-GCM (.i) Chromium ≥ 80 plain DPAPI before v80 plaintext FIREFOX / NSS — branches on the DER object identifier key4.db own SQLite (.h) key3.db Berkeley DB (.p) DER parser .Q (895 insns) .q model .s · 3DES via reflection OID …1.12.5.1.3 · legacy .r · AES-256-CBC OID …2.9 + PBKDF2 · modern plaintext Both paths are implemented for each family: the payload decrypts credentials from current installs and from builds a decade old alike. Highlighted in colour, the path that applies to an up-to-date machine.
Neither path depends on system libraries: the payload brings its own SQLite reader, its own Berkeley DB parser and its own ASN.1 decoder. That is why no reference to nss3.dll or System.Data.SQLite appears — the two most obvious signatures of a browser credential stealer.

The targets

Chromium browsers, 26 of them enumerated: Opera, Yandex, Iridium, Chromium, 7Star, Torch, Cool Novo, Kometa, Amigo, Brave, CentBrowser, Chedot, Orbitum, Sputnik, Comodo Dragon, Vivaldi, Citrio, 360, Uran, Liebao, Elements, Epic Privacy, Coccoc, Sleipnir 6, QIP Surf and Coowon. UC Browser, Edge and QQ get their own module.

Safari for Windows deserves a separate mention: rather than implementing a binary plist parser, it invokes Apple’s own plutil.exe — a signed third-party binary — to convert the keychain to XML before parsing it. Living off someone else’s land.

Cookies have their own, wider list, with 12 Gecko browsers via cookies.sqlite. K-Meleon, WaterFox, BlackHawk and CyberFox are touched for cookies only, never for credentials.

And 39 application modules, each isolated in a Visual Basic On Error so that one failure does not break the chain:

CategoryApplicationsPredominant technique
MailOutlook, Foxmail, Opera Mail, eM Client, Mailbird, Becky!, The Bat!, IncrediMail, PocoMail, Eudora, Claws Mail, Thunderbird, Postbox, SeaMonkeyRegistry and DPAPI; 3DES with bespoke substitution alphabets in The Bat! and Opera Mail; PBKDF2 with a hardcoded key in eM Client
FTP and SFTPFileZilla, WinSCP, SmartFTP, FlashFXP, WS_FTP, FTP Navigator, FTP Commander, FTPGetter, CoreFTPXML, INI and a bespoke hex deobfuscation in WinSCP
VPNNordVPN, OpenVPN GUI, Private Internet AccessDPAPI, with extra entropy for OpenVPN
MessagingPidgin, Trillian, Psi/Psi+, PaltalkPlaintext XML and DPAPI
Remote desktop and DBWindows RDP, RealVNC 3.x and 4.x, TightVNC, MySQL WorkbenchRegistry and DPAPI over credential blobs
OtherJDownloader, DynDNS Updater, NO-IP, Windows Credential ManagerEmbedded fixed keys and vaultcli.dll

What shipped switched off

Several capabilities are compiled but inert. What makes this interesting is that it can be proven: in both cases the switch has a single writer — the static constructor, which sets it to zero — and the readers use it as a guard.

FeatureState
Second persistence, via %startupfolder%\%insfolder%\%insname%dead The entry point skips the whole block. Those placeholders are never reached, so the only live persistence is the stub’s
The IP Address: line in the reportdead It blocks both the lookup and the line itself. And even enabled, the fetcher calls WebRequest.Create('%'): the unsubstituted placeholder
Tor exfiltration over HTTP POSTinactive Fully implemented, proxy included, but its destination URL was left as a template

All three share a cause: this build left the builder with several options unconfigured. A useful reminder that “the malware has capability X” and “the malware does X” are different claims, and the difference is sometimes two bytes in a static constructor.

Detection

Six YARA rules, from the most generic to the most specific, anchored on bytes verified against the binaries rather than on guesswork:

RuleAnchorStages
Xor70_PrimitiveIL 1F 70 61, the shared primitive. Exactly one occurrence in each stage that uses it2, 3
Dropper_ConfuserEx_StegoThe PNG resource name, more than 50 occurrences of U+200B in UTF-8 and the decoy0
Loader_IExpandoThe namespace plus the pieces of the Pico2012 → Invoke trick2
Stub_NETReactor.NET Reactor sentinels, the native delegate and the decoy library3
Payload_XorStringTableIL of the table[i] ^= i ^ K loop, with field tokens and the constant wildcarded5
CampaignRandom resource names from this specific build3, 4

All five stages are covered, and the negative controls — the PNG, the encrypted blob, the XML, native binaries — trigger none of them.

An honest limitation: these were not tested against a corpus of benign .NET binaries, so the false-positive risk is estimated by construction rather than measured. The shared primitive rule is three bytes of IL: use it as a supporting signal, never alone. And stage 5 has not one plaintext string, so its detection rests entirely on the IL pattern.

What I could not close

Two things, and neither is a detail.

None of this was observed on real Windows. I extracted the bytes of all five stages and each is a structurally valid PE, and the SMTP configuration comes from the very code that consumes it: that part is strong evidence. But the injection, the scheduled task, the Defender exclusion and the SMTP traffic were read in code, not watched happening. The distinction matters.

Pivoting was not done; it did not come back negative. Without access to a threat intelligence API I was limited to web search, which does not index that kind of database. There were no hits for the hash or the domain, but that does not mean “no known activity”: it means “not checked”. Real pivoting would need VirusTotal or MalwareBazaar, passive DNS on the domain, and correlation by resource names against a sample repository.

There is also an opaque block of some 900 instructions without a single string, and eight stage 3 strings whose offset is computed at runtime.