Qilin Ransomware Up Close: A Themida-Wrapped Windows Loader and the ESXi Encryptor Behind It
Two files arrived from one incident: a 4 MB Windows executable, and an extensionless Linux binary pulled off an ESXi datastore. (A third file was submitted as well, but it was that same Linux binary again, byte for byte — it is analysed once.) The Windows sample turned out to be a commercial protector wrapped around something we could never see. The Linux file turned out to be completely unprotected and completely unbreakable. This is what static analysis could and could not get out of them — and why the honest answer to “can you decrypt it?” is no.
01 — Verdict
What the two samples actually are
| Sample | What it is | Recoverable? |
|---|---|---|
| 1.exe.bin4,001,808 bytes · PE32+ x86-64 | Windows GUI executable protected with Oreans WinLicense (the licensed sibling of Themida, same SecureEngine core). Stage 1 unpacked cleanly; the original program behind it is encrypted and virtualised. | Stage 1 only. The payload stays encrypted until runtime. |
| 170,608 bytes · ELF64 x86-64 · submitted twice | Qilin / Agenda ESXi encryptor — stripped C, hand-rolled crypto, no packer. Drops the ransom note, force-powers off VMs through vim-cmd, encrypts VMware artefacts. | No. Per-file X25519 ECDH against an operator key that is not in the binary. |
Everything below is static analysis only. No sample was executed, no command contained in a sample was run, and no network traffic was generated. The one unpacking step that succeeded was a Python re-implementation of the malware’s own decompressor, fed from the file image — the sample itself was only ever read as bytes.
Duplicate handling. Three files were submitted. Two of them are byte-for-byte identical: matching MD5, SHA-1 and SHA-256, and confirmed with a direct byte comparison rather than matching hashes alone. That pair is one sample, analysed once. Everything below covers two distinct samples.
02 — The Windows loader
A black box inside a black box
The Windows sample is a 12-section PE32+ GUI executable with an unusual shape. Seven of its twelve section names have been overwritten with eight spaces, so the file itself no longer records whether the original code lived in .text, .rdata or .data. Two sections give the protector away by name: .boot and .themida.
The layout of .themida is the part that matters. It declares a virtual size of 0x5DC000 — exactly 6,144,000 bytes — with a raw size of zero. It is a 6 MB read-write-executable uninitialised region, reserved in the address space but occupying nothing on disk. The protector allocates a scratch buffer the size of the payload it is about to decrypt, then transfers control straight into it.
| Artefact | What it tells us |
|---|---|
| Seven blank section names | The original program’s section semantics and toolchain fingerprint were deliberately erased. |
6 MB RWX .themida BSSraw size 0, virtual size 0x5DC000 | Runtime scratch/landing zone for the decrypted image and for generated code. Memory scanners that only trust image-backed sections will not see the payload. |
| No ASLR, no DEP opt-inDllCharacteristics 0x8020 | DYNAMIC_BASE and NX_COMPAT are both unset, so the image loads at its preferred base — which is precisely what the entry stub’s hard-coded base arithmetic relies on. |
| Five imports | Only GetModuleHandleA, CommandLineToArgvW, BCryptGenRandom, SystemFunction036 and NtCreateNamedPipeFile. Everything else is resolved at runtime. |
| Import table misaligned by one byte | The import descriptor RVA and every hint/name pointer are off by a byte. Windows’ loader tolerates it; most parsers and emulators do not. |
| Three relocations, twelve sections | No debug directory, no overlay, non-zero checksum. The file looks clean to naive validators. |
The entry point is a small custom decompressor. It computes the module base from a return address subtracted from a hard-coded constant, tests a flag dword to see whether the image has already been expanded, then expands the payload in four chunks and jumps to it. The codec is a bit-level LZSS in the aPLib family — its length thresholds and its “reuse last distance” state are aPLib’s own, and its gamma codes interleave value and continuation bits, which is why off-the-shelf extractors choke on it.
.text:00000000003E458 e8 82 01 00 00 call sub_3E5DF ; prepare + chunked decode
.text:00000000003E45D 41 52 push r10 ; -- decoder prologue --
.text:00000000003E462 41 52 push r10
.text:00000000003E464 49 8b 72 10 mov rsi, [r10+10h] ; in = source
.text:00000000003E468 49 8b 7a 20 mov rdi, [r10+20h] ; out = destination
.text:00000000003E46D b2 80 mov dl, 80h ; bit buffer, sentinel bit set
.text:00000000003E46F 8a 06 mov al, [rsi] ; first byte is a literal
.text:00000000003E47E 00 d2 add dl, dl ; getbit macro
.text:00000000003E480 75 07 jne short 3E489
.text:00000000003E482 8a 16 mov dl, [rsi]
.text:00000000003E487 10 d2 adc dl, dl ; (b<<1)|1 - never zero
.text:00000000003E489 73 e4 jae short 3E46F ; bit 0 -> literal We transcribed that decoder instruction by instruction into Python, fed it the compressed stream at file offset 0x3E649 with the sample’s own parameters, and ran it. It produced 4 chunks of 1,536,000 bytes — 6,144,000 bytes total, from 3,745,801 compressed bytes. That output size matches the declared .themida virtual size to the byte, and the stream terminated cleanly on an end-of-stream marker at the end of every chunk. Three independent signals agreeing is what makes this a genuine unpack rather than a plausible-looking failure.
What we recovered is not Qilin. It is the protector’s own runtime: roughly five thousand printable strings, essentially all of them Oreans internals — licensing registry paths, WinLicenseInstance, SplashClassName, and the protector’s own build path c:\miniprojects\x86il\il86\x64\release\IL86.pdb, which names the x86IL virtualisation engine shared by Themida and WinLicense. Three high-entropy regions remain, the largest roughly 440 KB of near-random data at the end of the image: the encrypted original program.
Zero occurrences of qilin, readme, wallet, bitcoin, esxi, vss, or any API or DLL name survive in the recovered image. The apparent hits on AES, RSA and COPY are byte coincidences inside x86 opcode streams — 41 52 53 41 5A 41 56 disassembles to push r10; push rbx; pop rdx; push r14 and simply reads as “ARSAZAV”. Twenty-six MZ pairs exist in the image; none forms a valid PE. Attribution for this sample therefore rests on the submitter, not on anything the file will confess to.
Getting further would mean reconstructing the SecureEngine virtual machine and its crypto. That is a research project, not a triage step. The practical routes to payload-level intelligence are memory forensics on an instrumented host after the sample decrypts itself in memory, or an unprotected build of the same sample.
03 — The ESXi encryptor
Fully readable, and that is the point
The Linux file stands in complete contrast. It is a 64-bit ELF executable of 70,608 bytes, stripped, dynamically linked, and unpacked and unobfuscated — plain compiled C at low optimisation, with every local variable spilled to the stack, an intact import table and cleartext strings throughout. Its hashes:
- SHA-256
- 426aedb3f00606a7cec74cf62487ef7c53896fa787fc63c02c348c98225d2903
- MD5 / SHA-1
- 6fb84ca063e03e7ce52374fab301f1597c231f653f3e97affb654964712e0c1d759c270b
Attribution is not a guess. Five independent markers sit in one binary: the note begins --Qilin; it carries a hard-coded Tox ID and a Proton Mail contact; the note file is named How To Restore Your Files.txt; encrypted files get the suffix .encrptd; and the payload logic is hypervisor-specific. The build fingerprint is mildly interesting too — the .comment section holds two GCC versions, 9.1.0 and a Debian 4.4.7, meaning a prebuilt third-party object (the well-known open-source C-Thread-Pool, recognisable from its error strings and its thread-pool-%d worker names) was linked into a newer build.
The execution path is short and linear. There is no persistence, no privilege escalation, no lateral movement, and no network capability at all — no socket, no connect, no send. All negotiation is manual, through the note.
- Take a root path and a list of VMs to spare.
argv[1]is the directory tree to walk; any further arguments are VM IDs the operator wants left running. The binary refuses to run with fewer than two arguments. - Enumerate the virtual machines. It shells out to
vim-cmd vmsvc/getallvms, filters the result down to numeric IDs and writes them to/tmp/running_vms.txt. - Force them off. For every VM not in the spared set, it checks
power.getstateand then runsvim-cmd vmsvc/power.off— a hard power-off. This is the whole trick: powering a VM off releases the exclusive locks on its.vmdkfiles, which is what lets the encryptor open them for writing. It retries five times, ten seconds apart, then gives up, and deletes its temporary file. - Size the thread pool at twice the CPU count and start encrypting in parallel. On a hypervisor this saturates every datastore queue at once, which is a large part of why these intrusions finish so quickly.
- Walk the tree and apply an allow-list. The walker drops the ransom note into every directory it enters, then filters filenames — and this orientation matters enormously. The five
strstrcomparisons do not mean “skip these types”; they mean only.log,.vmdk,.vmem,.vswpand.vmsnare encrypted and everything else is silently skipped. A copy of this binary dropped on a general Linux file server would encrypt almost nothing. - Encrypt each file in place in 10 MiB chunks, overwriting the plaintext with ciphertext. The loop is capped at 10,240 chunks, so a single file is never encrypted beyond 100 GiB.
- Append the key trailer and rename. 32 bytes are written at the end of every encrypted file, and the file is renamed to
<name>.encrptd. Files already carrying that suffix are skipped, so the encryptor is idempotent — re-running it damages nothing.
The note contains a per-victim identifier baked in at build time. That has a practical consequence for defenders: a campaign that tailors a build per target produces a different hash for every victim, even though the code is identical. Hunting this family by hash alone will always lag behind.
04 — The crypto
Per-file key agreement, and nothing to recover
All of the crypto is hand-rolled in C. There is no OpenSSL, no libtomcrypt, no mbedTLS — and, notably, no weakness. The chain is textbook ECIES-shaped:
- A fresh 32-byte scalar per file, from
/dev/urandom. The kernel CSPRNG. There is norand(), no time-seeded generator, no LCG anywhere in the key path. - X25519 with that one scalar, twice. Once against the Montgomery base point (
u = 9) to derive the ephemeral public key, and once against a hard-coded recipient public key to derive a shared secret. The scalar is clamped with the standard X25519 profile, then wiped from memory. - SHA-256 over the raw shared secret — a hand-written implementation, giving a 32-byte digest that becomes the file’s master key.
- A TEA/XXTEA-family key expansion. The 32-byte digest is mixed as eight 32-bit words using the classic golden-ratio delta
0x9E3779B9— deployed as a long series of decremented deltas rather than a single addition — with roughly a hundredROL 11steps. It is TEA-like but definitely bespoke, not a stock implementation. - A proprietary ARX stream cipher. 48 bytes of state, 20 rounds per block, 80 bytes of keystream per generation. Not AES, not ChaCha/Salsa, not RC4: there is no
expand 32-byte kconstant, no AES S-box, no RC4 key schedule. - A constant, zero IV — harmless here, and worth understanding why: every file has its own 256-bit key derived from its own ECDH agreement, so a fixed IV cannot cause keystream reuse across files. The uniqueness lives in the key, not in the nonce.
- 32 bytes of ephemeral public key appended to the file, then a rename. Ciphertext and plaintext are the same length; the encrypted file is exactly 32 bytes larger than the original.
Two 256-entry lookup tables drive the keystream, and it is tempting to call them S-boxes. They are not, and testing rather than eyeballing is what settles it. Both tables are permutations of 256 distinct 32-bit values and are GF(2)-linear — T[0] = 0 and T[i ^ j] = T[i] ^ T[j] hold for every pair. A table that is simultaneously a permutation and linear over GF(2) is provably a linear map: a pure XOR diffusion matrix. All of the cipher’s non-linearity comes from the arithmetic in the round function — the shifts, the rotates and an unidentified 32-bit multiplier, 0x54655307.
We checked specifically for the mistakes that make ransomware recoverable, and every check came back negative. No hard-coded symmetric key: the only secret input is produced by ECDH against a public key. No constant or derivable ephemeral scalar: it comes from the kernel CSPRNG and is wiped before the file is renamed. No weak PRNG. No nonce reuse. The operator’s private key is not in the binary, and no flaw in the key schedule or the stream cipher offers a shortcut around it. A decryptor cannot be built from this sample, and payment should not be presented to stakeholders as a guaranteed route out — Qilin is a ransomware-as-a-service operation, and its decryptors have historically been unreliable.
There is one narrow exception worth checking before writing a large datastore off as a total loss. Because the encryption loop stops at 100 GiB per file, any virtual disk larger than that is only partially encrypted — the tail beyond the boundary is still plaintext and may be directly recoverable.
05 — Indicators
What to look for
| Where | Indicator |
|---|---|
| Datastore | Files renamed with a .encrptd suffix, and every encrypted file exactly 32 bytes larger than its original — that trailer is the ephemeral public key. |
| Every directory | A file named How To Restore Your Files.txt, 8,193 bytes, CRLF line endings, beginning --Qilin. |
| Live process | Threads named thread-pool-1, thread-pool-2, … visible in /proc/<pid>/task/*/comm. The reused open-source pool leaves this behind. |
| Host logs | vim-cmd vmsvc/getallvms and especially vim-cmd vmsvc/power.off in shell history or audit logs, plus a cluster of VMs that shut down unexpectedly inside a short window. Also /tmp/running_vms.txt, which the binary deletes on success. |
| Built-in fragments | The note filename never appears contiguously in the binary — it is reassembled at runtime from movabs immediates. Raw strings output shows only fragments like /How To H, so an IOC written from strings alone will get the filename wrong. |
| Windows sample | A PE32+ with 12 sections, seven of them named with eight spaces, plus .themida (raw size 0, multi-megabyte RWX virtual size), .boot and .tls; no DYNAMIC_BASE and no NX_COMPAT; five imports with misaligned pointers. |
Both sample hashes, plus the one build-time datapoint that survives: the Windows loader is stamped 2026-09-06 14:29:53 UTC. The ELF file carries no equivalent — its section headers are stripped.
- Windows loader — 4,001,808 bytes
- 4e5711815797f8183a5837cd828b9580c52191c2d2e96849de50a544f7a07b46
- MD5 / SHA-1
- c5c609db2a6780c7310b37e8d704de02054a8d3cb3ea9724509695986f639eacfa4b4b3d
- ESXi encryptor — 70,608 bytes
- 426aedb3f00606a7cec74cf62487ef7c53896fa787fc63c02c348c98225d2903
- MD5 / SHA-1
- 6fb84ca063e03e7ce52374fab301f1597c231f653f3e97affb654964712e0c1d759c270b
06 — Detection
Four cheap tests that catch this
# 1. The highest-confidence on-disk indicator
for f in walk(datastore):
if f.endswith(".encrptd"):
ALERT family=Qilin file=f size_delta=32
# 2. The note
if exists("<dir>/How To Restore Your Files.txt"):
if read(first 64 bytes).strip().startswith("--Qilin"):
ALERT family=Qilin dir=<dir>
# 3. A live encryptor, from its thread names
for pid in pids():
for t in glob(f"/proc/{pid}/task/*/comm"):
if read(t).startswith("thread-pool-"):
ALERT possible=Qilin-encryptor pid=pid
# 4. Hypervisor suppression - the step that comes before encryption
grep "vim-cmd vmsvc/power.off" /var/log/vmware/* /root/.bash_history Test 4 is the one with the most warning value. The power-off has to happen before any file is touched, so an alert on vmsvc/power.off invoked by anything other than a known administrative parent is an alert on the intrusion, not on the damage.
07 — Response
Do not reboot the host
- Isolate the management interface, but leave the host powered on. The encryption is in place, there is no C2 and no second stage, and the binary is a single-shot tool. Powering the host off gains nothing and costs you the process table, the thread names and the shell history.
- Preserve the trailers. The final 32 bytes of every encrypted file are the only decryption material that exists. Deleting or truncating
.encrptdfiles destroys the recovery path even for a legitimate key holder. - Snapshots and backups are the only reliable recovery. Verify restores before mounting them.
- Check VMDKs over 100 GiB individually for a recoverable plaintext tail before assuming total loss.
- Then close the door. Isolate or disable ESXi shell access on production hosts — the binary has no remote-exploitation capability of its own, so it needs a shell to be launched. Enforce MFA and least privilege on vCenter and ESXi management interfaces, keep them off the internet, and restrict critical operations such as VM power-off to your normal change process so that a deviation is visible.
08 — Method
What this analysis was, and was not
Static analysis throughout: ELF and PE header inspection, segment-aware address mapping, Python re-implementation of the packer’s decompressor, Capstone disassembly with call-target function recovery, and programmatic property testing of the cipher tables. Nothing was executed.
Two things follow from that and should be read as limits, not caveats. First, the Windows sample’s family attribution comes from the submitter, not from the file — no family-specific string survives stage 1, because the payload is still encrypted. Second, no formal cryptanalysis of the proprietary stream cipher was performed. Its structure is described from its constants and control flow, and its tables were proven linear; no claim is made about its strength beyond that. Neither limit changes the recovery verdict, which rests on the absence of the operator’s private key rather than on the strength of the cipher.
Prepared from static analysis of two distinct samples — three files were submitted, one of them a byte-identical duplicate — collected on 12 September 2026 and reported to CyberSecurity Malaysia. The underlying report is classified TLP:AMBER; this write-up reproduces only the part of it that is useful for detection.
No sample was executed at any point in this work. Victim-specific negotiation artefacts — the contact address, the Tox ID and the per-victim identifier — are deliberately not reproduced here. They identify one incident rather than the family, and nothing in them helps anyone detect the next one.