David Rosas — research
11 Oct 2026·ZeuS·Reverse engineering

A key that is the relocation table and a thread that starts in the wrong place.

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.

What ZeuS was, and what the sources say

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 there is nothing to unpack

"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 key is the relocation table

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.

PE header NumberOfSections > 2 section [2] = .reloc RVA 0x25000 · 5,738 B Config in the clear C2 URL · RC4 key Encrypted blob, 812 B inside .text, at 0x402d88 XOR byte by byte The same blob, UNDECRYPTED, is also the key of an RC4 that protects the containers. Two roles, one piece of data.
The relocation table exists in every PE, looks like noise, nobody looks at it, and it costs not a single byte of 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.

A container that proves itself

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.

A thread that starts in the wrong place

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.

New process, suspended its first thread waits at RtlUserThreadStart Reflective copy of the image RWX of SizeOfImage · real relocations ctx.Eax = the bot's entry one register, before the thread exists It starts in the bot, not the program And it gives control back It ate the thread's start address, so it has to hand it back. Instead of saving it, it RECOMPUTES it from the host's PE header and jumps there. The program starts normally. Nobody notices.
The operating system creates the thread. The bot only changes where it begins, and then hands control back to the legitimate program.

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.

Mutexes that look like COM objects

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.

One key per program

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.

ProgramWhere it reads fromWhat it does with the password
Windows Mail / Live MailRegistry + account{*}.oeaccountHex, then DPAPI with the entropy read from the registry
FlashFXPRegistry → sites.datCyclic XOR with a fixed key, plus a subtraction with a conditional decrement
Total CommanderRegistry → wcx_ftp.iniFour passes driven by a congruential generator
WinSCPRegistryXOR with 0x5c, a length header and padding
SmartFTPFavourites XMLXOR with a 122-word key built on the stack
FAR managerRegistryk = (b[0] ^ b[1]) | 0x50, then XOR
FTP Commanderftplist.txtXOR with 0x19
WS_FTP, FileZilla, CoreFTPRegistry, INI, XMLNothing: 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.

SOCKS5 and a desktop you cannot see

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.

Origin: what the binary does not say

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 sitsWhat is here
Resources (VERSIONINFO, LANGID)There is no .rsrc section at all
Rich header (exact compiler version)Wiped
Strings in the author's languageAll 230 decrypted ones are ASCII English, not one byte >127
Cyrillic code-page constantsNot 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.

Quality as a piece of software

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 madeWhere the seams show
A length disassembler so it never cuts an instruction in half when saving the prologue it hooksFIXME 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 firstBOT NOT CRYPTED!, a debug message left inside
SOCKS5 with BIND, UDP ASSOCIATE, domain names and IPv6A 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 messagesThe VNC has no authentication: security type "none"
A reflective PE load with real relocations, patching its own base in the remote copyNo compression in the VNC, and only two of the six encoding types
A single event turns off 56 hooks in every process at onceCollapsed SOCKS error codes: four of the nine reduce to "generic failure"
It reimplements each victim program's cipher, Delphi's generator includedCurrentversion 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 elevateHextile 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.

Detection

What really matters here are the memory and behaviour artefacts, because they survive any recompilation of the kit:

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.

What is left, and whether dynamic is worth it

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.

Sources for the public context