Recently, I received a freshly compiled set of decryption tools obtained through direct negotiations with the Interlock ransomware threat group. As you may know, ransomware decryptors rarely reach public analysis. Victims who pay don't usually share the tools. These tools are useful artifacts that can help clarify how a particular ransomware's crypto works. That might be why public reporting on Interlock still disagrees with itself about something as basic as which cipher it uses.

Predictably, the decryption tools (with one exception) are heavily obfuscated - the Windows tool is nearly 10 MiB, full of thousands of junk words and functions. The x64 and ARM versions of the Linux tool were similarly padded. Surprisingly, the ESXi tool was only compiled and then stripped of symbols. This made the analysis fairly straightforward.

File Information

File name: esxi
Type: ELF 64-bit LSB executable, x86-64, dynamically linked, stripped
Build: GCC 4.1.2 20080704 (Red Hat 4.1.2-55)
Entry point: 0x4015c0
Malware Family: Interlock (ESXi/Linux variant)
Reference: CISA AA25-203A

No hash, no VirusTotal link

You'll notice this post has no SHA256 and no sandbox report, which breaks with how I normally do these.

That's because this binary contains a victim's RSA private key in plaintext, compiled directly into its data section.

Everything in this post is published with the affected organization's permission. All key material and every environment-specific detail has been removed.


0x01 Why Bother

The decryptor worked. So why take it apart?

Mostly for practice, and because it's interesting. By analyzing the crypto libraries and functions used, we get the full picture of what happened during the locking process. A decryptor written by a ransomware crew isn't commercial software, and before running one across a datastore full of the only surviving copy of something, it seems worth knowing what it does when it fails.

That instinct turned out to be correct - I identified four separate ways this tool can destroy data silently. I also learned the following:

  • The intermittent encryption stride is progressive, not fixed. Coverage of a large file works out to about 8.7%, and it's deliberately front-loaded. Makes sense when your files are potentially multiple terabytes in size.
  • The cipher is AES-256-GCM, and the authentication tag is computed and then thrown away. Functionally it's CTR mode with 64 KiB of wasted lookup tables.
  • There are two different file-handling classes, one of which leaves filename and size completely untouched.

None of the above appear in any public reporting I could find.


0x02 First Look

The file is a standard stripped ELF binary. Loading the bin in Ghidra gives you several hundred FUN_004XXXXX entries and no hints.

The first useful thing was in the .comment section:

GCC: (GNU) 4.1.2 20080704 (Red Hat 4.1.2-55)

A 2008 compiler, CentOS 5 era, repeated about 230 times. This is an eighteen-year-old toolchain, and the library requirements explain why it was used:

Symbolglibc version
nftw642.3.3
realpath2.3
everything else2.2.5

The binary's floor is glibc 2.3.3, released in 2004, and it's set by exactly one function. I imagine this is to maximize portability and compatibility in the event an old server was hit.

The .ctors/.dtors/.jcr sections back this up - modern GCC uses .init_array instead. Same with the presence of _Jv_RegisterClasses, a GCJ artifact nobody has emitted in over a decade.

What Is Missing From The Import Table

62 imports are present, and the gaps tell you a lot:

  • No socket, connect, getaddrinfo. Zero networking. This tool can't phone home to a C2 or fetch keys. Everything it needs is inside it.
  • No fork, execve, system, popen. It can't run other processes, which means all the vim-cmd/esxcli VM-shutdown commands reported with ESXi lockers live in the encryptor, not this tool.
  • No mkstemp or tmpfile - the tool writes straight over the original bytes instead of doing a write-then-rename. If anything goes wrong mid-file, there's nothing to fall back to.

That last one (mkstemp) is the root cause of most of 0x07.

Name fprintf pthread_cond_init rename

_Jv_RegisterClasses fread pthread_cond_signal rmdir

__errno_location free pthread_cond_wait setvbuf

__gmon_start__ fseeko64 pthread_create sleep

__libc_start_main ftello64 pthread_detach srand

__strdup ftruncate64 pthread_join stderr

__xstat64 fwrite pthread_mutex_destroy strcat

abort malloc pthread_mutex_init strchr

calloc memcpy pthread_mutex_lock strcmp

close memset pthread_mutex_unlock strcpy

closedir mkdir rand strdup

exit nftw64 read strlen

fclose open readdir64 sysconf

fileno opendir realloc sysinfo

flock pthread_cond_broadcast realpath time

fopen64 pthread_cond_destroy remove


0x03 Getting The Names Back

After some initial digging, I noticed that the binary has LibTomCrypt (LTC) statically linked into it, and that LTC has a very convenient habit.

Its argument-checking macro looks roughly like this:

#define LTC_ARGCHK(x) if (!(x)) { crypt_argchk(#x, __FILE__, __LINE__); }

__FILE__ means every function that validates its inputs leaves its own source filename sitting in .rodata. And that string is referenced from inside the function it came from.

Here's the macro snippet from the source:

#define LTC_ARGCHK(x) do { if (!(x)) { crypt_argchk(#x, __FILE__, __LINE__); } }while(0)

This made the process straightforward: find the strings, walk the cross-references backwards, then rename the containing function.

A quick pyghidra script automates it. Two implementation details I discovered that mattered:

Non-PIE absolute immediates. This binary is EXEC, not DYN, so string pointers load as mov edi, 0x430c40 - a plain scalar. It turns out that Ghidra doesn't reliably create data references for those, so the script builds its own index of every scalar operand in .text and merges it with the reference manager's results.

Multiple functions per source file. One __FILE__ string is referenced by every function in that file, so the names come out incrementally (ltc_aes_1 through ltc_aes_4) and have to be examined and renamed afterwards.

The final result after running the script was 132 functions across 72 source files.

Symbol tree after labeling

Everything still named FUN_* after that pass is the malware's own code - about a dozen functions. The script is on GitHub if it's useful to anyone; it should work against any LTC-linked sample.

LibTomCrypt itself is public domain (Unlicense) - full credit to Tom St Denis and contributors: github.com/libtom/libtomcrypt.

This was only part of the solution. To really make sense of the functions a lot of manual cleanup was required. Ghidra left everything as a void, with incorrect calling conventions. The cleanup was hit-or-miss for a while, and I'm not claiming 100% accuracy.

Cleaning up a function

Note to self: work backwards, not forwards

The productive move after running it isn't to read through the remaining FUN_* list. It's to pick the LTC anchors - ltc_rsa_decrypt_key, ltc_gcm_init - and look at which functions called them. The malware's crypto orchestration is by definition the set of functions that call the crypto library.


0x04 The Crypto

This is the part I most wanted to figure out. Public sources disagree here about which crypto libraries Interlock uses. CISA's advisory describes AES and RSA (correct for ESXi, at least); several vendor writeups describe a ChaCha20/RSA-4096 hybrid. Both ciphers are compiled into this binary, so both readings are understandable.

decrypt_file_strided in the Ghidra decompiler

For this tool the following cipher is the only one that touches file data:

AES-256-GCM

cipher = find_cipher("aes");

gcm_init(state, cipher, key, 0x20); // 0x20 = 32 bytes = AES-256

gcm_add_iv(state, iv, 0x10); // 16-byte IV

gcm_process(state, out, n, in, 1); // 1 = GCM_DECRYPT in LTC

The full pcode from 0x004016a0:

/* AES-256-GCM, strided in-place decryption.

1 MiB chunks; skip starts at 1 MiB, +512 KiB per pass, caps at 10.5 MiB.

GCM tag is computed and discarded - no integrity check anywhere. */

void decrypt_file_strided(FILE *fp,uint8_t *key,uint8_t *iv,size_t file_size)

{

int rc;

uint8_t *in_buf;

uint8_t *out_buf;

size_t chunk_len;

long rem_after;

long skip;

long stride;

uint8_t gcm [69904];

uint8_t tag [16];

ulong tag_len [2];

chunk_len = 0x100000;

if ((long)file_size < 0x100001) {

chunk_len = file_size;

}

in_buf = malloc(chunk_len);

out_buf = malloc(chunk_len + 0x40);

rc = ltc_crypt_find_cipher("aes");

rc = ltc_gcm_init(gcm,rc,key,0x20);

if ((rc != 0) || (rc = ltc_gcm_add_iv(gcm,iv,0x10), rc != 0)) {

free(in_buf);

free(out_buf);

return;

}

if (0 < (long)file_size) {

stride = 0x100000;

do {

chunk_len = 0x100000;

if ((long)file_size < 0x100001) {

chunk_len = file_size;

}

chunk_len = fread(in_buf,1,chunk_len,fp);

if ((chunk_len == 0) || (rc = ltc_gcm_process(gcm,out_buf,chunk_len,in_buf,1), rc != 0))

break;

rem_after = file_size - chunk_len;

fseeko64(fp,-chunk_len,1);

fwrite(out_buf,1,chunk_len,fp);

skip = stride;

if (rem_after <= stride) {

skip = rem_after;

}

file_size = rem_after - stride;

fseeko64(fp,skip,1);

if (stride < 0xa00001) {

stride = stride + 0x80000;

}

} while (0 < (long)file_size);

}

free(in_buf);

free(out_buf);

tag_len[0] = 0x10;

ltc_gcm_done(gcm,tag,tag_len);

return;

}

The IV is 16 bytes. GCM's canonical nonce is 96 bits; anything else gets folded through GHASH to derive J0. This is fully supported, but still an unusual choice.

The gcm_state stack buffer is 69,904 bytes, which means LTC_GCM_TABLES was enabled at build time - 64 KiB of precomputed GHASH tables (remember that number, it comes back in a moment).

ChaCha20 Is A Random Number Generator

Every caller of chacha_setup, chacha_crypt, and chacha_keystream lives inside src/prngs/chacha20.c or its self-test. Nothing in the malware's own code calls them.

Function Call Trees for ltc_chacha_setup

So why does a decryptor need a PRNG (pseudo random number generator) at all? Because LibTomCrypt's rsa_exptmod takes a prng_state and uses it to blind the private-key operation against side-channel attacks. ChaCha20 is there to service one RSA call.

That resolves the reporting conflict: AES-256-GCM for bulk data, RSA-4096-OAEP for key wrap, ChaCha20 as a PRNG only. Of course, this is all scoped to this ESXi ELF variant - I haven't checked the Windows build yet.

A Trap Worth Knowing About

I got the crypto chain analysis wrong before getting it right, because of how LibTomCrypt dispatches ciphers. My first attempt at deciding which cipher was live was to count Ghidra cross-references. AES's ecb_decrypt appeared to have callers only inside LTC's own self-test, so I incorrectly concluded AES was dead code.

LibTomCrypt dispatches ciphers through function pointers in a descriptor table. Calls go out as cipher_descriptor[idx].ecb_encrypt(...). Ghidra doesn't resolve those into cross-references. A function can look completely unreferenced and still be running on every byte of data.

This is lifted straight from GCM's own source:

/* encrypt original counter */

if ((err = cipher_descriptor[gcm->cipher].ecb_encrypt(gcm->Y_0, gcm->buf, &gcm->K)) != CRYPT_OK) {

return err;

}

for (x = 0; x < 16 && x < *taglen; x++) {

tag[x] = gcm->buf[x] ^ gcm->X[x];

}

*taglen = x;

cipher_descriptor[gcm->cipher].done(&gcm->K);

The right way is to read the descriptor structure directly (variables renamed by me):

aes_desc structure in Ghidra

OffsetValueField
0x00"aes"name
0x0c16min_key_length
0x1032max_key_length
0x1416block_length
0x200x404560setup
0x280x404190ecb_encrypt
0x300x403d40ecb_decrypt

The real struct declaration, trimmed to the fields decoded above (ID, default_rounds, keysize, and the accelerator block are omitted for length):

extern struct ltc_cipher_descriptor {

const char *name;

int min_key_length,

max_key_length,

block_length;

int (*setup)(const unsigned char *key, int keylen, int num_rounds, symmetric_key *skey);

int (*ecb_encrypt)(const unsigned char *pt, unsigned char *ct, symmetric_key *skey);

int (*ecb_decrypt)(const unsigned char *ct, unsigned char *pt, symmetric_key *skey);

int (*test)(void);

void (*done)(symmetric_key *skey);

Or, since walking a struct out of raw bytes is half the fun, here it is untyped:

; ltc_cipher_descriptor aes_desc

00431500 50 0c 43 00 00 00 00 00 name -> 0x430c50 "aes"

00431508 06 00 00 00 ID = 6

0043150c 10 00 00 00 min_key_length = 16

00431510 20 00 00 00 max_key_length = 32 <- AES-256

00431514 10 00 00 00 block_length = 16

00431518 0a 00 00 00 default_rounds = 10

0043151c 00 00 00 00 (alignment padding)

00431520 60 45 40 00 00 00 00 00 setup -> 0x404560

00431528 90 41 40 00 00 00 00 00 ecb_encrypt -> 0x404190 <- live

00431530 40 3d 40 00 00 00 00 00 ecb_decrypt -> 0x403d40 <- dead

00431538 40 54 40 00 00 00 00 00 test -> 0x405440

00431540 c0 3c 40 00 00 00 00 00 done -> 0x403cc0

00431548 d0 3c 40 00 00 00 00 00 keysize -> 0x403cd0

00431550 00 ... 00 accel_* pointers, all NULL

That independently confirms AES-256 is permitted, and gives you the encrypt/decrypt functions by address rather than by inference.

Note that test precedes done in LTC's struct layout. I got that order wrong initially, which shifted every pointer after it by eight bytes.

Every accel_* slot is NULL - no mode accelerators are implemented, so GCM runs through the generic path.

The self-test data sitting immediately after confirms this is stock AES rather than a modified variant:

004315c0 10 00 00 00 keylen = 16

004315c4 00 01 02 03 ... 0e 0f key

004315e4 00 11 22 33 ... ee ff plaintext

004315f4 69 c4 e0 d8 6a 7b 04 30 expected ciphertext

d8 cd b7 80 70 b4 c5 5a <- FIPS-197 AES-128 vector

That last line is, it turns out, straight out of the published standard, so anyone can check it without touching the sample. The 192- and 256-bit vectors follow at 0x431604 and 0x431648.

There are also two cipher descriptors compiled in. rijndael_desc sits just before this one - same implementation, different name string. Only aes_desc is ever registered.

For the record, ecb_decrypt is dead here - but for a reason that has nothing to do with cross-references. GCM is CTR plus GHASH, and CTR only ever runs the block cipher forward. Decrypting under GCM calls ecb_encrypt to generate a keystream, exactly as encrypting does. The decrypt path and its 4 KB of Td tables are along for the ride.


0x05 An Interesting Mistake

I had to do a bit of a crypto deep dive during this analysis, and I learned that GCM is an authenticated cipher. It produces a 16-byte tag that tells you whether the ciphertext was tampered with, or whether you used the wrong key.

gcm_done makes that verification possible by returning an error code the caller is meant to check:

int gcm_done(gcm_state *gcm,

unsigned char *tag, unsigned long *taglen)

{

...

return CRYPT_OK;

}

Here is the last thing the decryption function does:

local_40[0] = 0x10;

gcm_done(state, tag_buffer, local_40);

return; // tag_buffer never compared to anything

The tag is computed into a local variable, the return value is discarded, and the function returns void immediately.

warning

The malware authors - and victims, by direct result - carry all the overhead of an AEAD, including those 64 KiB of GHASH tables from earlier, and never use the one thing it provides. Cryptographically this is AES-256-CTR with expensive dead weight.

The practical consequence is there is no point at which this tool can detect that something went wrong. A wrong key, a corrupt footer, or an I/O error mid-stream all end the same way: garbage written in place over the original data, with no error reported.

As an engineering artifact, this fascinates me. Somebody knew enough to reach for an authenticated mode, and then either didn't understand what the tag was for or couldn't be bothered to store it. Given the encryptor presumably has the same gap, there may be no tag stored anywhere in the file format at all.


0x06 The Stride

This is my favorite part of the sample.

Intermittent encryption isn't new, but this is my first time getting my hands on malware using this method. How it works is you encrypt part of a file, skip part of it, and get through the datastore before anyone notices. The file data is still rendered useless until decryption, and it allows threat actors to lock data quickly. What's unusual here is that the skip grows.

chunk 1 MiB, always

stride starts at 1 MiB

grows by 512 KiB every iteration

caps at 10.5 MiB

Twenty distinct stride values: 1.0, 1.5, 2.0 … 10.0, 10.5 MiB.

Simulating the exact loop gives the coverage curve:

File sizeEncryptedCoverage
≤ 1 MiBall of it100%
10 MiB4.0 MiB40.0%
100 MiB17.0 MiB17.0%
1 GiB98.0 MiB9.57%
100 GiB8.7 GiB8.70%
500 GiB43.5 GiB8.70%

Stride coverage visualization

Most intermittent schemes use a fixed stride or a flat percentage. A widening stride front-loads the damage: most of it lands early in the file and thins out toward the end.

Which is exactly right for VM storage. VMDK descriptors, partition tables, and filesystem superblocks all live near offset zero. Wreck the first few hundred megabytes and the remaining 90-odd percent of intact plaintext is unreachable anyway. It's a smart tradeoff between damage and speed.

Order Matters

The GCM state is initialized once, before the loop, then fed each chunk in sequence. So the counter advances only over processed bytes, not skipped ones. Keystream position is cumulative-bytes-processed, not file offset.

Three consequences:

  • Chunks can't be decrypted independently or out of order.
  • Decryption must start at offset 0 and follow the identical stride schedule.
  • A reimplementation with slightly wrong constants desynchronizes after the first gap and silently corrupts everything downstream - with no tag check to catch it.

0x07 Four Ways To Lose Data

1. No integrity check. Covered above. The tag is discarded.

2. The decryption routine can't report failure. decrypt_file_strided returns void. An internal cipher failure just breaks the loop.

3. Truncation is unconditional. For footer-class files the tool strips the trailing 514 bytes after decrypting, whether or not decryption succeeded. There's a rollback in the code, but it only covers failures before the cipher starts running.

4. Key files are deleted first. For the sidecar class (see 0x08), the key file is remove()d immediately after being read - before decryption begins. If decryption then fails, you have a partially decrypted file and no key to retry with.

If you are ever handed one of these

Image the datastore before running it, every time.

The tool overwrites in place, can't detect its own failures, can't report them, and in one mode has already destroyed the key material by the time anything goes wrong.


0x08 Two Kinds Of Encrypted File

There are two entirely separate handling paths, distinguished by where the wrapped key lives.

This is the typical Interlock encrypted file format: locked files are renamed with a .1nt3rlock extension and have 514 bytes appended.

┌────────────────────────────────────────────┐ offset 0

│ ciphertext (strided) │ total − 514

├────────────────────────────────────────────┤

│ RSA-4096 wrapped key blob 512 bytes │

├────────────────────────────────────────────┤

│ uint16 little-endian = 0x0200 2 bytes │

└────────────────────────────────────────────┘

The trailing two bytes are a length field for the blob preceding them. The parser bounds-checks it and then demands it equal exactly 0x200 - a variable-length field pinned to one value.

One-line file identification

Those trailing two bytes encode the RSA modulus size. 00 02 means a 512-byte blob, so RSA-4096.

tail -c 2 suspect.vmdk | xxd

Sidecar Class

The file keeps its original name and its original size. Nothing about it looks encrypted. The key lives in a mirrored directory tree at the root of the filesystem:

/!_KEYS_FOR_DECRYPT_!/vmfs/volumes/datastore1/web01/web01-flat.vmdk

The path builder canonicalizes the target path, replaces every : with ~, and prepends the keystore root:

void build_keystore_path(char *target_path,char *out)

{

size_t len;

char canonical [4104];

canonicalize_path(target_path,canonical);

replace_char(canonical,':','~');

builtin_strncpy(out,"/!_KEYS_FOR_DECRYPT_!",0x16);

if (canonical[0] != '/') {

len = strlen(out);

(out + len)[0] = '/';

(out + len)[1] = '\0';

}

strcat(out,canonical);

return;

}

That colon substitution is a small tell. VMFS datastore paths carry colons in UUID references, and the mirrored path has to stay valid. Combined with the ESXi bootbank exclusions below, it's clear this was written for VMware environments rather than adapted from a generic Linux locker.

Both classes converge on the same 48-byte plaintext once the blob is unwrapped:

[0x00 .. 0x1F] AES-256 key 32 bytes

[0x20 .. 0x2F] GCM IV 16 bytes


0x09 The Embedded Key

The decryptor carries the victim's RSA-4096 private key, in the clear, in its data section. The operator compiles a fresh binary per paying victim.

Here is the structure, with all integer content removed:

63dd20 30 82 09 28 SEQUENCE, 2344 bytes

02 01 00 INTEGER version = 0 (two-prime)

02 82 02 01 [REDACTED] INTEGER 513 bytes modulus n

02 03 01 00 01 INTEGER e = 65537

02 82 02 00 [REDACTED] INTEGER 512 bytes privateExponent d

02 82 01 01 [REDACTED] INTEGER 257 bytes prime1 p

02 82 01 01 [REDACTED] INTEGER 257 bytes prime2 q

02 82 01 01 [REDACTED] INTEGER 257 bytes exponent1

02 82 01 00 [REDACTED] INTEGER 256 bytes exponent2

02 82 01 00 [REDACTED] INTEGER 256 bytes coefficient

63e64c 2c 09 00 00 uint32 LE = 0x92c = 2348 = blob length

A textbook PKCS#1 RSAPrivateKey, followed by a 4-byte length field. So the layout in .data is:

struct { uint8_t der[2348]; uint32_t len; };

I ran the standard weakness checks against the key before publishing anything about it, and the keygen is sound.


0x0A What Got Excluded

The sample carries within it a list of things not to touch:

Exclusions

The __WHY_ORDER_MATTERS__.txt is the ransom note, which includes instructions on how to contact the group using Tor. Also listed are ESXi hypervisor bootbank modules. Elsewhere in the file sits a general Linux top-level directory skiplist - /boot, /proc, /sbin, /lib64, /lost+found and such.

List of exclusions:

; char skip_list[][0x20]

00431120 "boot.cfg"

00431140 ".sf"

00431160 ".b00"

00431180 ".v00"

004311a0 ".v01"

004311c0 ".v02"

004311e0 ".v03"

00431200 ".v04"

00431220 ".v05"

00431240 ".v06"

00431260 ".v07"

00431280 ".t00"

004312a0 "__WHY_ORDER_MATTERS__.txt"

004312c0 ".gz"

004312e0 ".tgz"

00431300 ".z"

The design is encrypt everything except what would stop the host booting. They want the hypervisor up and reachable so the victim can read the note and reach the negotiation portal. A host that won't boot can't pay.

This is also why grepping the binary for vmdk, vmx, esxcli, or vim-cmd comes back completely empty when I tried. The tool defines its targets by what it skips rather than what it hits, which cost me some headaches before I worked out what I was looking at.

The ransom message in __WHY_ORDER_MATTERS__.txt goes to /etc/motd, so it appears on login.


0x0B Detection Notes

Things defenders can look for that don't require the sample.

Filesystem:

  • /!_KEYS_FOR_DECRYPT_!/ at the root of any filesystem - a mirrored directory tree, one small file per sidecar-class target. It's also an exact inventory of what was hit that way.
  • .1nt3rlock file extension
  • Ransom text in /etc/motd
  • __WHY_ORDER_MATTERS__.txt

File contents:

  • Trailing two bytes equal to 00 02
  • Files under 1 MiB that are entirely high-entropy
  • Large files with high-entropy regions in a widening pattern

For YARA authors, one thing that will bite you: one of the interesting strings - /!_KEYS_FOR_DECRYPT_! - never exists contiguously in the binary.

The decompiler hides this completely. It renders the path setup as:

builtin_strncpy(out,"/!_KEYS_FOR_DECRYPT_!",0x16);

which looks like an ordinary string literal. It isn't. Drop to the listing and the string is assembled from four immediate stores:

Stack-constructed string in the listing

00401eaa 48 ba 2f 21 5f 4b 45 59 53 5f MOV RDX, 0x5f5359454b5f212f ; "/!_KEYS_"

00401eb4 48 b8 46 4f 52 5f 44 45 43 52 MOV RAX, 0x524345445f524f46 ; "FOR_DECR"

00401ebe 48 89 13 MOV qword ptr [RBX], RDX

00401ec1 48 89 43 08 MOV qword ptr [RBX + 0x8], RAX

00401ec5 c7 43 10 59 50 54 5f MOV dword ptr [RBX + 0x10], 0x5f545059 ; "YPT_"

00401ecc 66 c7 43 14 21 00 MOV word ptr [RBX + 0x14], 0x21 ; "!\0"

Four stores - 8 + 8 + 4 + 2 = 22 bytes, which is the 0x16 the decompiler showed as the strncpy length. The immediates are already in memory order, so they decode directly:

>>> parts = [(0x5f5359454b5f212f, 8), # RDX

... (0x524345445f524f46, 8), # RAX

... (0x5f545059, 4), # dword imm

... (0x21, 2)] # word imm

>>> [v.to_bytes(n, 'little') for v, n in parts]

[b'/!_KEYS_', b'FOR_DECR', b'YPT_', b'!\x00']

>>> b''.join(v.to_bytes(n, 'little') for v, n in parts)

b'/!_KEYS_FOR_DECRYPT_!\x00'

>>> len(_)

22

As a one-liner:

python3 -c "print(b''.join(v.to_bytes(n,'little') for v,n in [(0x5f5359454b5f212f,8),(0x524345445f524f46,8),(0x5f545059,4),(0x21,2)]))"

b'/!_KEYS_FOR_DECRYPT_!\x00'

Now we can trace the raw byte stream and the strings output explains itself:

$ strings -a esxi | grep -A2 KEYS

/!_KEYS_H

FOR_DECRH

YPT_f

strings isn't mangling anything here. After the "/!_KEYS_" immediate comes 0x48 - the REX.W prefix of the MOV RAX on the very next line - which strings prints as H. Same again after "FOR_DECR". And after "YPT_" comes 0x66, the operand-size prefix of the 16-bit store, which prints as f. Each trailing character is just the opcode prefix of the next instruction.

A rule matching the assembled path will never fire

The bytes /!_KEYS_FOR_DECRYPT_! don't appear consecutively anywhere in the file. Match the fragments, or match the instruction encoding:

$a = { 48 ba 2f 21 5f 4b 45 59 53 5f 48 b8 46 4f 52 5f 44 45 43 52 }

That covers both immediates and the REX prefix between them, which is considerably more specific than either fragment alone.

Better anchors: the custom base64 alphabet 0123456789A-Za-z_. (using _ and . where RFC 4648 uses + and /), and the GCC 4.1.2 / Red Hat 4.1.2-55 .comment fingerprint.


0x0C Still Open

The following items remain unsolved (by me, at least):

  • Which files get footer vs. sidecar handling. That decision is made by the encryptor, which I don't have.
  • Whether the Windows variant matches. Not yet analyzed.

This was a fun one. Next up is the x64 Linux tool, then eventually the final boss that is the Windows version.