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.

2distinct samples
6.14 MBrecovered from the loader
1duplicate collapsed
0network calls in the encryptor

01 — Verdict

What the two samples actually are

SampleWhat it isRecoverable?
1.exe.bin4,001,808 bytes · PE32+ x86-64Windows 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 twiceQilin / 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.
Scope

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.

ArtefactWhat it tells us
Seven blank section namesThe original program’s section semantics and toolchain fingerprint were deliberately erased.
6 MB RWX .themida BSSraw size 0, virtual size 0x5DC000Runtime 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 0x8020DYNAMIC_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 importsOnly GetModuleHandleA, CommandLineToArgvW, BCryptGenRandom, SystemFunction036 and NtCreateNamedPipeFile. Everything else is resolved at runtime.
Import table misaligned by one byteThe 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 sectionsNo 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.

The string trap

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.

  1. 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.
  2. 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.
  3. Force them off. For every VM not in the spared set, it checks power.getstate and then runs vim-cmd vmsvc/power.off — a hard power-off. This is the whole trick: powering a VM off releases the exclusive locks on its .vmdk files, 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.
  4. 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.
  5. 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 strstr comparisons do not mean “skip these types”; they mean only.log, .vmdk, .vmem, .vswp and .vmsn are encrypted and everything else is silently skipped. A copy of this binary dropped on a general Linux file server would encrypt almost nothing.
  6. 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.
  7. 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:

  1. A fresh 32-byte scalar per file, from /dev/urandom. The kernel CSPRNG. There is no rand(), no time-seeded generator, no LCG anywhere in the key path.
  2. 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.
  3. SHA-256 over the raw shared secret — a hand-written implementation, giving a 32-byte digest that becomes the file’s master key.
  4. 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 hundred ROL 11 steps. It is TEA-like but definitely bespoke, not a stock implementation.
  5. 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 k constant, no AES S-box, no RC4 key schedule.
  6. 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.
  7. 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.

Why you cannot build a decryptor

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

WhereIndicator
DatastoreFiles renamed with a .encrptd suffix, and every encrypted file exactly 32 bytes larger than its original — that trailer is the ephemeral public key.
Every directoryA file named How To Restore Your Files.txt, 8,193 bytes, CRLF line endings, beginning --Qilin.
Live processThreads named thread-pool-1, thread-pool-2, … visible in /proc/<pid>/task/*/comm. The reused open-source pool leaves this behind.
Host logsvim-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 fragmentsThe 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 sampleA 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 .encrptd files 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.