David Rosas — research
Oct 11, 2026·Dridex·Reverse engineering

A payload in 61 pieces and a PE header that erases itself.

A one-megabyte executable of which only 100 KB is malware: the rest is random noise. The payload travels split into 61 fragments that are only decrypted once reassembled, the PE header is rebuilt field by field in memory and wiped again afterwards, and there is not a single real string or import. The folder said Zeus; it is Dridex.

Sample 6e05e84c7a993880409d7a0324c10e74 Compiled Jul 3, 2019 Family Dridex Target Windows · native PE32

What it is, and what it isn’t

The sample is a one-megabyte native PE32 posing as a DirectX DLL: it carries version information stolen from a legitimate Delphi project, with the name d3dx81ab.dll and the string “DirectX 8.1 D3DX8 DLL for JEDI projects”. The compilation date says 2002. That is fake; the payload it hides was built in July 2019.

Inside is a Dridex loader, the lineage descending from Cridex and Bugat. It is not a complete banking trojan: it is the piece that prepares the ground. It deploys through DLL side-loading, elevates privileges by abusing system binaries that auto-elevate, injects into other processes and talks to a network of peers. The banking modules come later and are not here.

The folder said Zeus, and it isn’t. The fastest way to rule that out was by absence: ZeuS exists to steal banking credentials through form-grabbing, by hooking the browser. I tested 21 API names characteristic of ZeuS against the 115 this sample resolves, and not one of them appears. No InternetReadFile or HttpQueryInfoA for web injects, no TranslateMessage for the keyboard, no GetClipboardData. A ZeuS with none of that is not a ZeuS.

Revised 11 October 2026. When I wrote this I had no real ZeuS to check against. Now I do, and the list was only half right: of the 21 names, 15 are present in a genuine ZeuS 2.0.8.9 and six are not. Those six discriminated nothing, because they come back "absent" on a real ZeuS too. Two mechanisms were misattributed: the real ZeuS does not use GetAsyncKeyState for the keyboard, it hooks TranslateMessage; and for Firefox it does not touch NSS_Init or PK11SDR_Decrypt but nspr4.dll — one layer lower, the socket before TLS, which is a different capability altogether. The conclusion stands: this is Dridex, proven by the autoElevate abuse, the side-loading, the peer-to-peer architecture and the date. But the reasoning cited markers that ZeuS itself does not have.

The Dridex attribution comes from the files it drops: two DLLs of 716 and 708 KB, labelled trojan.dridex/cridex, named WINSTA.dll and VERSION.dll. Those names are not incidental: they are DLL side-loading targets. And they line up with what I had already read in the binary before consulting anything external.

What I found in the binaryThe Dridex behaviour that explains it
<autoElevate>truePrivilege elevation: a system binary that auto-elevates, with a DLL planted beside it
winsxs, wow64, *.exe, *.dllSweeping directories for side-loading targets
Tasks\, NTUSER.DATScheduled-task persistence and registry hive access
No keylogger, no NSS, no web injectsThe loader is separate from the banking modules
No C2 URL embedded anywhereDridex runs on peers, not against a fixed server

The four-stage chain

0 · d3dx81ab.dll crypter stub · 1,008 KB Gathers 61 scattered fragments and decrypts them with RC4, 52-byte key 1 · reassembled buffer 942,080 B · three regions Filler, loader and payload split apart at the offset the stub itself computes 2 · reflective loader 9,728 B · a single import Subtracts a 32-byte key and rebuilds the PE header field by field 3 · the bot 930,816 B · DLL, 432 functions of which only 100 KB is code
Each hop changes technique. Neither stage 2 nor stage 3 ever touches disk.

The last two stages exist only in memory. The stub builds them, maps them and jumps in; they are never written out. That matters for detection: the rules covering them are for process dumps, not for files.

StageWhat it isSizeMD5
0Crypter stub1,032,1926e05e84c7a993880409d7a0324c10e74
1Reassembled, decrypted buffer942,08071b2680ce618e0ba3e25baaa03fa8f79
2Reflective PE loader9,72862b66560da6ef356a564ff9bc87bb3f4
3The bot930,816e2554b59145e67c7d1ca6f2da742910e

The payload in 61 pieces

The first stage does not store the payload as one block. It keeps it split into 61 fragments scattered across two sections with junk names, aei and 9B|PsbB+, plus one more hidden in .data. A 61-entry table, with a stride of 160 bytes, says where each one comes from and how long it is.

in the file, scattered .data · 1 aei · 16 9B|PsbB+ · 44 61-entry table, stride 0xa0 concatenated in memory 942,080 bytes, still encrypted RC4, a single pass, 52-byte key plaintext The table fixes the order: without it, the key is useless.
Gather first, decrypt second. Separating those two operations is what breaks carving.

What turns this into a defence rather than a curiosity is the order. RC4 is a stream cipher: the key drives a continuous keystream whose position depends on how many bytes you have consumed. By encrypting the already-concatenated buffer, each fragment ends up under a different stretch of that stream, and which stretch depends on the sum of the lengths of everything before it. Even with the key, an isolated fragment will not decrypt: you need the whole table to know where in the stream it starts.

The length fields finish the job. Each entry holds a lo and a hi, and the length is the difference. The bases are random and different in every entry — the first starts at 0xa86afbb2 — so the numbers mean nothing on their own. Only the subtraction does.

Stages 2 and 3 are stored without a valid PE header. It is not that the header is encrypted: specific fields are missing, and the list is surgical.

on disk in memory once mapped e_magic e_lfanew PE signature Machine NumberOfSections SizeOfOptHeader Magic writes “MZ” 0xc8 “PE\0\0” 0x14c · i386 6 0xe0 0x10b · PE32 wipes “MZ” 0xc8 00 00 00 00 00 00 00 00 wiped in the file valid, for milliseconds signature zeroed again A scanner walking the process memory finds no executable. And static carving over the file finds none either: the fields are not there.
The missing fields are exactly the first things any PE parser checks.

Look at which fields they are: e_magic, e_lfanew, the signature, Machine, NumberOfSections, SizeOfOptionalHeader and Magic. That is the precise list a parser reads to decide “this is a PE”. Everything else — SectionAlignment, FileAlignment, ImageBase, the whole section table — is left intact.

That asymmetry is what allows rebuilding them without running anything, along two independent paths that confirm each other. The first is to read the values straight out of the loader, which writes them as constants:

*(uint16*)img          = 0x5a4d;    // “MZ”
*(uint32*)(img+0x3c)   = 0xc8;      // e_lfanew
*(uint32*)(img+0xc8)   = 'PE\0\0';
*(uint16*)(img+0xcc)   = 0x14c;     // Machine = i386
*(uint16*)(img+0xce)   = 6;         // NumberOfSections
*(uint32*)(img+0xf0)   = 0x65ac;    // AddressOfEntryPoint

The second is to derive them from what survives. If the section table sits at offset 0x1b8 of the image, then e_lfanew has to be 0x1b8 − 0x18 − 0xe0 = 0xc0, because those are the fixed sizes of the signature, the COFF header and the optional header. And there is a third check that settles it: the SizeOfImage read from the rebuilt header is 0xe8000, which is exactly the size the loader asks for in its VirtualAlloc. Two different places in the binary saying the same thing.

830 KB of noise

Of the bot’s 930 KB, 830 are random filler that nothing reads. The .sdata section takes 811 KB on its own, and another 20 KB are scattered through .rdata. The real code is 90 KB.

930,816 bytes, to scale 90 KB code 4 KB data 830 KB of random filler zero references point here Entropy 8.0 across the whole block. Entropy cannot tell filler from ciphertext: both are uniform. What separates them is whether anything references it.
The filler inflates the image, defeats size heuristics and exhausts emulators that cap how much they scan.

Telling filler from encrypted data has a catch, because by entropy they are identical: both read 8.0. And there is a second trap. Measured in 256-byte blocks, that same region reads 7.15, which looks like “not encrypted” until you remember that in 256 draws you cannot observe all 256 possible values. For uniform data the expected entropy is 8 − 255/(2n·ln2), that is 7.28 at n=256 and 7.82 at n=1024. Measuring 7.15 in small blocks is exactly what chance predicts.

What does settle the question is pointers. The bot’s relocation table has 207 entries, and not one points into .sdata. A sweep for 32-bit values in that section’s range yields 130 matches, but every one is overlapping bytes from other instructions: none is preceded by an opcode that actually loads an address. Nobody reads those 811 KB.

A binary with no strings and no imports

The bot imports four functions: Sleep, FreeConsole, OutputDebugStringW and little else. And of 1,449 candidate strings, none is readable. Everything else it resolves by CRC32 hash of the name, masked with a constant:

stored_hash = crc32(name) ^ 0x322efb6c

That is 171 call sites, 11 DLLs and 115 distinct functions. Breaking that scheme is what turns 432 opaque functions into readable code, and once broken the profile reads itself:

LibraryFunctionsWhat for
NTDLL29NtQueueApcThread, NtMapViewOfSection, NtWriteVirtualMemory: APC injection and section mapping
KERNEL3255Processes, files, named mutexes, and WOW64 awareness
ADVAPI3216Registry, tokens and SIDs
WININET4InternetConnectW, HttpOpenRequestW: the network channel
USER32, PSAPI, SHLWAPI…11Window titles and classes, process enumeration, sessions

Two details of the scheme are easy to miss. DLL names are hashed uppercase and with the extension (KERNEL32.DLL), while function names go exactly as exported. And the binary uses two schemes at once: the mask applies only to the API resolver. When it compares loose strings it uses raw CRC32, no mask, which is why crc32("http") and crc32("https") show up untouched when it parses the URL.

The few strings it genuinely needs are assembled character by character on the stack, so they do not exist in the file:

push 0x6e ; call append    ; 'n'
push 0x74 ; call append    ; 't'
push 0x64 ; call append    ; 'd'
...                        ; → "ntdll.dll"

Recovering that pattern yields 22 strings, and the interesting ones are few but very telling: <autoElevate>true, winsxs, wow64, Tasks\, NTUSER.DAT, *.exe, *.dll, USERNAME. The first gives away the privilege elevation: it looks for system binaries whose manifest auto-elevates them, so it can ride a legitimate elevation.

It hooks exactly what it uses

The bot installs inline hooks on four ntdll functions, and the choice is eloquent:

Hooked functionWhy that one
NtProtectVirtualMemoryThe one you must call to patch someone else’s memory: hooking it, it sees who tries to touch its own
NtWriteVirtualMemorySame, for the write
NtQueueApcThreadIts own injection route
NtTerminateProcessSurvival: it sees attempts to kill it coming

Before installing its own it does something more interesting: it checks whether someone else got there first. It reads the first byte of each function and compares it to 0xE9, the opcode for a relative jump. If it finds one, a security product is already hooked in. In that case it assembles the string "ntdll.dll" letter by letter, reloads the library to recover the original bytes, and only then installs its own.

The network channel has another detail worth a look. When it opens the connection it calls InternetSetOptionW with INTERNET_OPTION_SECURITY_FLAGS and the value 0x3380, which decomposes cleanly into all five TLS validations: revocation, unknown authority, wrong usage, name mismatch and expired date. All disabled. It can talk to any server presenting any certificate.

Why there is no C2

I looked for an embedded URL or domain and there is none. The function that opens the connection receives the host already parsed from another that hands it over, and the character-by-character string builder only ever produces the scheme, https. For a while it looked like a dead end.

It isn’t: it is how the family works. Dridex does not talk to a fixed server but to a network of compromised machines acting as proxies. There is no domain to burn because there is no domain. The sample’s public telemetry shows it plainly: dozens of unrelated IP addresses spread across the world, almost all with no detections. That is not command infrastructure; it is a peer list.

It is a good reminder that the absence of an indicator can be the indicator. “I can’t find the C2” and “this family has no C2 to find” look very similar from inside the disassembler, and only the second one is a conclusion.

Detection

Seven layered YARA rules, from generic to specific. The distinction that matters is what survives the actor recompiling:

AnchorWhat it catchesSurvives a rebuild?
Code bytes of RC4 and of the string decryptorThe crypter, whatever the keyYes
52-byte RC4 key, 16-byte key, stolen resourceThis specific campaignNo
The movs that restore the PE headerThe reflective loaderYes
The 0x322efb6c mask and the derived DLL hashesThe botWhile the mask holds
The 16 bytes of the four hooked-function tableThe botYes

The rules fire on all four stages, each on the one it belongs to. As negative controls I ran 399 benign native PEs, 8 .NET binaries from another family, 205 macOS executables, plain text, two megabytes of random data and — the most useful of all — the same sample before stripping its last encryption layer. Zero detections across all of them. That last one confirms the rules key on real content and not on a coincidence of shape.

The negative corpus is small and not very varied, so the false-positive risk against a real estate is estimated, not measured. The most exposed rule is the section heuristic — two large high-entropy sections and a ridiculous import table — which any commercial packer can trip. I would not deploy it on its own.

As host indicators, the strongest are the names of the planted DLLs: WINSTA.dll and VERSION.dll appearing next to a system executable copied outside System32. A legitimate Windows binary running from a writable directory with a DLL of those names beside it is the signature of side-loading.

What I couldn’t close

Nothing was executed. The whole chain was unpacked statically, and that has a clear limit: the four stages are structurally valid PEs and the algorithms come from the very code that uses them, so that part is strong evidence. But the injection, the scheduled task, the autoElevate elevation and the network traffic I read in the code — I did not watch them happen. The distinction matters.

The attribution is inference, not reading. That it is Dridex rests on the label of the files it drops, on the side-loading names, and on the techniques I found independently matching the family. It is a well-supported inference, but it is not the same as having read “Dridex” inside the binary. What is verified is the mechanism; the family is the best explanation I have.

And I got something wrong along the way that is worth telling. I searched for the hash on a web search engine, got nothing, and wrote “not publicly indexed”. That was false: the sample had been in the public databases since July 2019. General search engines do not index that kind of record. The correct statement was “not consulted”, which is a much weaker and much more honest claim.

Fourteen of the 115 function hashes remain unresolved, for want of their names in the dictionary, along with the format of a record marked by the constant 0x534b5c36 that appears twice in the data and is compared in the code. That is where I would keep pulling.