ZeuS 2.0.8.9 does not hide its configuration behind a key stored somewhere: it
encrypts it against its own relocation table, which exists in every PE and which nobody looks at.
And to inject into other processes it does not call CreateRemoteThread: it rewrites
one context register before the thread exists, and lets Windows create it wherever it
says. This sample was built a month before the source code of that same version turned up
online.
Worth starting with the context, because it is what gives everything else meaning. ZeuS was the banking trojan that defined the category: a kit that was sold, with a control panel, which stole banking credentials by intercepting the browser from the inside.
What is publicly documented about this particular version, with the sources alongside:
And here is what this sample adds: it was compiled on 14 April 2011. If iDefense's date holds, this build is from the same weeks as the leak; if the press date holds, it precedes it by a month.
What the sources do not say. On authorship, what is verifiable is that Evgeniy
Bogachev was indicted twice in the United States — as lucky12345 in Nebraska in August
2012, and under his real name in the Western District of Pennsylvania in May 2014, on 14 counts —
accused of acting as an administrator of the Gameover Zeus botnet, with a separate
complaint tying him to an earlier variant called Jabber Zeus. His aliases include "Slavik". The FBI
estimates more than one million infections and losses above 100 million dollars.
What the sources consulted do not establish is that he wrote the original ZeuS. They tie him to two later variants. Saying he authored this sample would go beyond what is verified, so I do not say it.
"This time" because the previous sample out of the same folder was a Dridex loader labelled as Zeus, with 830 KB of random filler and its payload split into 61 pieces. This one is the opposite.
141 KB, three sections with ordinary names, 347 imports across 13 DLLs, entropy 6.70 and not one
uniform block. None of that looks like packed malware, and it is not: it is the whole bot, straight
out of the kit's builder, with no crypter on top. It even carries the warning string the bot itself
displays when it detects that none was applied: BOT NOT CRYPTED!
The huge import table is misleading. A pile of GUI functions
— DefMDIChildProcA/W, DefFrameProcA/W, GetUpdateRgn,
SaveDC — look like padding meant to blend in. They are the opposite: they are
exactly the 56 APIs the bot hooks, imported so it can call the original after
patching it.
So the work was not in the wrapper but inside. And the first thing was the silence:
strings returns 718 lines of garbage and 24 loose strings. The real ones are 230, and
they live in a table where each string carries its own key:
BYTE key; BYTE pad; WORD length; DWORD pointer
plain[i] = cipher[i] ^ key ^ i
The index goes into the calculation, so two identical letters do not produce the same byte and a
single-byte XOR over the whole binary finds nothing. Break that and the full capability inventory
appears: the 25 control-server commands, the ten FTP clients it loots, the mail ones, the registry
paths, and even a block of user_pref(...) that disables five Firefox security
warnings.
The static configuration — the one holding the control server's address and the botnet's master key — is 812 encrypted bytes inside the binary. The interesting part is that the key is not stored anywhere.
The code locates it at runtime: it reads its own PE header, checks there are at least three sections, goes to the third one and uses its contents as the XOR key.
Why it works so well: relocations are in every executable, their contents look random, and nobody thinks to look for a key there. If an analyst rebuilds the binary or strips the table, the configuration stops decrypting.
And there is a detail that dovetails with the next piece: when the bot copies itself into another
process, it rebases by applying the real relocations. The contents of .reloc do
not relocate themselves, so they stay identical after the rebase and the key survives. The
two parts were designed together.
Inside those 812 bytes: the control server's configuration URL, three pairs of timers, a botnet name the operator left empty, and an already-expanded master RC4 key — a full permutation of 0 to 255, 258 bytes — which encrypts both the dynamic configuration it downloads and the reports it uploads.
Appended to the end of the executable, outside the image that gets loaded into memory, sit 512 bytes of noise. The bot finds them by mapping its own file and walking it byte by byte, decrypting only four bytes at each position until a signature appears.
+0x000 DWORD 'DAVE'
+0x004 DWORD crc32(+0x08 .. +0x200)
+0x008 WORD payload length
+0x00a payload + RANDOM padding up to 512
the whole block under RC4, signature included
That CRC32 is what turns the decryption into proof rather than a guess. Hitting a four-byte signature is already one in four billion; having 32 bits of checksum over the remaining 504 also match closes the question. Searching the whole file for the encrypted signature gave exactly one hit and no false positives.
The payload is twelve bytes. And they turned out to be the missing piece somewhere else: a
four-byte XOR key and two lengths, 692 and 505. Those numbers are exactly the size of two
installer functions that are encrypted on disk and which the bot decrypts at
runtime between two changes of memory protection. With the right key, both start with
55 8b ec 83 e4 f8 81 ec: a textbook compiler prologue. With the other two numbers,
garbage.
In other words: the bot carries two functions that do not exist as code until it runs, and the key travels hidden inside the encrypted container at the end of the file. A static disassembler sees 1,197 bytes of noise and assigns them no function.
This is where ZeuS parts ways with almost everything that came after. To get into other processes
it uses no shellcode, no CreateRemoteThread on its main path, and not one byte of code
foreign to its own executable. It injects itself, whole, and lets the operating system create
the thread.
It hooks process creation — NtCreateUserProcess on Vista and later,
NtCreateThread on XP — and in the handler it does this:
GetThreadContext(thread, ctx) ; ContextFlags = 0x10003
if (ctx.Eip != RtlUserThreadStart) abort
ctx.Eax = remote_base + bot_entry ; offset 0xb0 of CONTEXT
SetThreadContext(thread, ctx) ; ContextFlags = 0x10002
On x86, a new thread starts at RtlUserThreadStart and the kernel passes it the start
address in the Eax register. By rewriting Eax in the
context before the thread exists, the bot changes where a thread created by Windows itself
begins. There is no remote-thread creation to log. And by writing only the integer registers, it
never touches Eip.
The check that Eip still equals RtlUserThreadStart is what guarantees it
only hijacks the initial thread of a freshly created process: there, and only there,
the thread's start address is the image's entry point, which is why it can be recomputed. For
processes already running it uses a second entry point that merely initialises and returns, because
that is an extra thread and there is nothing to hand back.
Two more details round off the design. When copying itself into the remote process it
patches its own base inside the copy, which is what keeps the .reloc
encryption alive. And it duplicates into the target the handle of an event that acts as a master
switch: 60 checks scattered through the code consult that event, so signalling it makes all 56 hooks
fall through to the original at once, in every process simultaneously. That is the
uninstall command.
The sample has not a single fixed mutex, event or file name. It derives them, and the result is shaped like a GUID:
GUID g = volume GUID of the Windows directory
g.Data1 ^= seed ; one per object
g.Data2 ^= LOWORD(seed)
g.Data3 ^= HIWORD(seed)
for i in 0..7: g.Data4[i] ^= bytes(crc32(user SID))[i % 4]
rc4(&g, 16, master_key)
name = ["" | "Local\" | "Global\"] + {GUID}
A defender hunting for suspicious mutex names sees Local\{DD02E4CD-5600-...} and
takes it for a legitimate COM object. It is the sample's best evasion and it costs nothing.
And it has an exploitable weakness. The RC4 state is re-copied on every call, so the key stream for those 16 bytes is always the same; and the seed only touches the first eight bytes. The consequence: on a given machine, all of ZeuS's object names share the last eight bytes of the GUID and differ only in the first ones.
That gives a detection that needs to know nothing about the machine or the botnet: look for two
objects named {GUID}, Local\{GUID} or Global\{GUID} whose last
two groups match and whose first three differ. In legitimate COM that does not happen.
What says most about the effort invested is not any single technique but the variety: ZeuS carries embedded the secret format of every program it loots.
| Program | Where it reads from | What it does with the password |
|---|---|---|
| Windows Mail / Live Mail | Registry + account{*}.oeaccount | Hex, then DPAPI with the entropy read from the registry |
| FlashFXP | Registry → sites.dat | Cyclic XOR with a fixed key, plus a subtraction with a conditional decrement |
| Total Commander | Registry → wcx_ftp.ini | Four passes driven by a congruential generator |
| WinSCP | Registry | XOR with 0x5c, a length header and padding |
| SmartFTP | Favourites XML | XOR with a 122-word key built on the stack |
| FAR manager | Registry | k = (b[0] ^ b[1]) | 0x50, then XOR |
| FTP Commander | ftplist.txt | XOR with 0x19 |
| WS_FTP, FileZilla, CoreFTP | Registry, INI, XML | Nothing: plaintext |
Two of those deserve a note. FlashFXP's "subtraction" carries a decrement conditional on the
carry: anyone reimplementing it as a clean modulo-256 subtraction gets garbage in half the bytes. And
Total Commander's generator multiplies by 0x08088405, which is the multiplier of
Delphi's Random() — and Total Commander is written in Delphi. ZeuS does not invent a
cipher: it reimplements the victim program's own, random generator included.
SmartFTP's key is the neatest detail in the block: 244 bytes that never exist as a
contiguous block in the file, because they are assembled by 122 chained instructions.
Neither strings nor a byte-pattern rule finds it.
And the form grabber does not merely watch: it reports the full POST and
then deletes those fields from the POST before forwarding it. The bank
never sees the stolen fields; the operator does. That is the difference between a sniffer and
ZeuS.
The largest function in the binary, 2,257 bytes, is a complete SOCKS5 server:
method negotiation, all three address types — IPv4, domain name and IPv6 — and all three commands,
including BIND and UDP ASSOCIATE. With real dual stack: it prefers the
family of the inbound socket and keeps the other as a fallback.
That is far more than malware usually ships, which stops at CONNECT over IPv4. And in
a banking trojan it is not a convenience tunnel: it is what defeats IP-based fraud
detection. A transfer initiated through the proxy on the victim's own machine comes from her
IP, her provider and her city. To the bank it is indistinguishable from the legitimate customer.
The other half of the same idea is a VNC server on a hidden desktop. Instead of routing traffic, the operator drives the victim's browser on a desktop she cannot see. It speaks real RFB 3.3, with all six client messages, and encodes in Raw and Hextile — with no compression — reducing colour depth to whatever the client asks for to save bandwidth.
It dovetails with the hooks in a way that is a pleasure to read: 24 of the 56 are governed by a
bit that only turns on in the hidden desktop, and what they do is redirect BeginPaint
and GetDC to a canvas in the bot's own memory. Applications draw somewhere
invisible and the VNC reads from there. The victim sees nothing because nothing is ever
painted on her screen.
And the name the server announces in the RFB handshake is, literally, FIXME.
These are two different questions and it pays not to mix them: who wrote the kit and who operated this build. The public context speaks to the first. On the second there is nothing published, and the indicators point at someone modest: an empty botnet name, a control server on free hosting, and no crypter on top.
From the binary, what stands out is what is missing:
| Where an origin clue usually sits | What is here |
|---|---|
Resources (VERSIONINFO, LANGID) | There is no .rsrc section at all |
| Rich header (exact compiler version) | Wiped |
| Strings in the author's language | All 230 decrypted ones are ASCII English, not one byte >127 |
| Cyrillic code-page constants | Not one occurrence |
The absence is information: the kit's builder scrubs the origin traces deliberately. The closest
thing to a human trace is two English strings, FIXME and
BOT NOT CRYPTED!
A test that looked like proof. A sweep for Cyrillic text in CP1251 returned 883
matches. It looked like a finding until I ran controls: 256 KB of random data gives 751, and
openssl gives 2,390. Every high byte decodes to Cyrillic in that code page, so the
measurement distinguishes nothing. Without the control, that gets published as "there is Russian text
in the binary".
And when language artefacts do appear they deserve suspicion: they are trivial to plant and are used as misdirection.
It is worth judging it as what it was — a product that was sold — because that explains both why it dominated its niche for years and where the seams show.
| What is well made | Where the seams show |
|---|---|
| A length disassembler so it never cuts an instruction in half when saving the prologue it hooks | FIXME as the VNC desktop name, travelling over the network |
| It detects existing hooks: it checks whether the prologue already starts with a jump, meaning another bot or an EDR got there first | BOT NOT CRYPTED!, a debug message left inside |
SOCKS5 with BIND, UDP ASSOCIATE, domain names and IPv6 | A verified bug: the WSASend hook never advances its pointer and only looks at the first buffer |
| Integrity in its own format: CRC32 in the container, MD5 in the network messages | The VNC has no authentication: security type "none" |
| A reflective PE load with real relocations, patching its own base in the remote copy | No compression in the VNC, and only two of the six encoding types |
| A single event turns off 56 hooks in every process at once | Collapsed SOCKS error codes: four of the nine reduce to "generic failure" |
| It reimplements each victim program's cipher, Delphi's generator included | Currentversion with a lowercase v in the registry path |
| A coherent design: it only injects into processes of the same user, aborts at low integrity, never tries to elevate | Hextile tripled by colour depth instead of parameterised |
| Measured stealth: faked timestamps on file and folder, but ordinary attributes — a hidden folder would draw more attention |
The verdict: serious systems engineering with product-level carelessness. The core mechanisms are solved with fluency and with details only someone who has suffered the problem before puts in. And they coexist with a to-do marker visible over the network and a badly written loop.
The likeliest reading is that this was not a polished product but a kit that was sold: what gave competitive advantage — stealing more, hiding better, injecting where others could not — is cared for. What did not show in the demo is not.
What really matters here are the memory and behaviour artefacts, because they survive any recompilation of the kit:
MZ/PE
header inside, in a process that never mapped that file. Plus a second small RWX region holding the
trampolines, 35 bytes per hook.GetThreadContext and SetThreadContext with flags 0x10003
and then 0x10002 on the initial thread of a freshly created process.NtCreateUserProcess and LdrLoadDll patched with a jump in a user
process.{GUID} whose last eight bytes match.FIXME.Run value rewritten every 200 milliseconds: deleting it while the bot is alive
achieves nothing.The static rules I wrote anchor the primitives — the string decryptor, the .reloc
trick, the container, the context hijack, the SOCKS5 server and the loop that decrypts the encrypted
functions — rather than this particular build. They fire on the sample and stay silent on nine
negative controls, among them another native malware family, random data and legitimate
binaries.
The limitation: they have not been tested against a corpus of benign Windows executables, so the false-positive risk is estimated by construction and not measured. That changes how they should be deployed.
The whole analysis is static: nothing was executed. Of the 450 functions the disassembler recognises, all but fifteen are classified — 917 bytes, 1.1% of the code. The largest block of what remains is not unknown capability but utility functions belonging to already-documented subsystems.
What cannot be reached from here, and is not a failure of the method: the contents of the dynamic configuration — the injection rules, the fake pages, the address where stolen data is uploaded — live on a server that has been dead for fourteen years. Running the sample does not recover it; you would need the file. With the extracted RC4 key, anyone who has it can decrypt it.
Is a dynamic analysis worth it? Yes, but narrowly. To validate the indicators — the predicted object names, the shape of the install path, decrypting real traffic — because a wrong indicator is worse than a badly explained mechanism. Not to understand the core mechanism: the context hijack cannot be validated on an ARM machine, because under x86 emulation the context the malware manipulates is the emulated one. Better to say so than to leave the impression that a step is missing which is in fact impossible there.
And one loose end stays unnamed, which I preferred to leave open rather than fill in: there is a configuration bit that, when set, stores in the thread's start register a value that is not a valid address — the thread would start in garbage — and which elsewhere triggers a forced logoff. It would fit either the destruction command or the one that disconnects the user. With the two fragments available it cannot be determined, so I do not label it.