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 binary | The Dridex behaviour that explains it |
|---|---|
<autoElevate>true | Privilege elevation: a system binary that auto-elevates, with a DLL planted beside it |
winsxs, wow64, *.exe, *.dll | Sweeping directories for side-loading targets |
Tasks\, NTUSER.DAT | Scheduled-task persistence and registry hive access |
| No keylogger, no NSS, no web injects | The loader is separate from the banking modules |
| No C2 URL embedded anywhere | Dridex runs on peers, not against a fixed server |
The four-stage chain
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.
| Stage | What it is | Size | MD5 |
|---|---|---|---|
| 0 | Crypter stub | 1,032,192 | 6e05e84c7a993880409d7a0324c10e74 |
| 1 | Reassembled, decrypted buffer | 942,080 | 71b2680ce618e0ba3e25baaa03fa8f79 |
| 2 | Reflective PE loader | 9,728 | 62b66560da6ef356a564ff9bc87bb3f4 |
| 3 | The bot | 930,816 | e2554b59145e67c7d1ca6f2da742910e |
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.
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.
The header that erases itself
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.
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.
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:
| Library | Functions | What for |
|---|---|---|
NTDLL | 29 | NtQueueApcThread, NtMapViewOfSection, NtWriteVirtualMemory: APC injection and section mapping |
KERNEL32 | 55 | Processes, files, named mutexes, and WOW64 awareness |
ADVAPI32 | 16 | Registry, tokens and SIDs |
WININET | 4 | InternetConnectW, HttpOpenRequestW: the network channel |
USER32, PSAPI, SHLWAPI… | 11 | Window 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 function | Why that one |
|---|---|
NtProtectVirtualMemory | The one you must call to patch someone else’s memory: hooking it, it sees who tries to touch its own |
NtWriteVirtualMemory | Same, for the write |
NtQueueApcThread | Its own injection route |
NtTerminateProcess | Survival: 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:
| Anchor | What it catches | Survives a rebuild? |
|---|---|---|
| Code bytes of RC4 and of the string decryptor | The crypter, whatever the key | Yes |
| 52-byte RC4 key, 16-byte key, stolen resource | This specific campaign | No |
The movs that restore the PE header | The reflective loader | Yes |
The 0x322efb6c mask and the derived DLL hashes | The bot | While the mask holds |
| The 16 bytes of the four hooked-function table | The bot | Yes |
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.