Skip to main content

When You Pay the Ransom - Taking Apart an Interlock ESXi Decryptor

Ian French
Documentation lover and occasional malware researcher

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 do not 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.

As you might expect, 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 will notice this post has no SHA256 and no sandbox report, which breaks with how I normally do these.

That is 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 is interesting. By analyzing the crypto libraries and functions used, we get the full picture of what happened during the locking process. It's also important to note that a decryptor written by a ransomware crew is not 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 is 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 is 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 is 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 absences are quite informative:

  • No socket, connect, getaddrinfo. Zero networking. This tool cannot phone home to a C2 or fetch keys. Everything it needs is inside it.
  • No fork, execve, system, popen. It cannot 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, tmpfile. No temp file, no write-then-rename. This tool writes straight over the original bytes. No second chances if anything goes wrong.

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

I first tried the lazy approach and just asked Claude to fix the file. As expected, the guardrails flagged the file as being associated with ransomware and the agent refused to help further. I was able to find a partial workaround by only pasting specific bits of pcode or assembly.

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.

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

I (actually Claude) wrote a quick pyghidra script to automate 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 does not 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 is useful to anyone; it should work against any LTC-linked sample.

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 is not to read through the remaining FUN_* list. It is 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 most interesting and insightful part. 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

Here's the full pcode snippet 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;
}

A couple of details are worth calling out.

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 have not 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 does not resolve those into cross-references. A function can look completely unreferenced and still be running on every byte of data.

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

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.

Also worth noting: there are 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 The Interesting Mistake

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.

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 - pay the entire cost of an AEAD, including hauling around those 64 KiB of GHASH tables mentioned earlier, and take none of the benefit. 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. Wrong key, corrupt footer, I/O error mid-stream - all of it decrypts silently into garbage, written in place over the original data.

I find this genuinely interesting as an engineering artifact. Somebody knew enough to reach for an authenticated mode, and then either did not understand what the tag was for or could not 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 is not 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 is 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 - dense at the start of a file, thinning toward the tail.

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 is a nicely judged tradeoff between damage and throughput.

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 cannot 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 cannot 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 is 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 first. Every time.

The tool overwrites in place, cannot detect its own failures, cannot 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 is 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 will not boot cannot pay.

This is also why grepping the binary for vmdk, vmx, esxcli, or vim-cmd comes back completely empty when I tried. Targeting is expressed as exclusions, not inclusions, which cost me 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 do not 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 is 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 is not. Drop to the listing and the string is assembled from four immediate stores:

Stack-constructed string in the listing

Here's a quick breakdown:

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

Those are not corrupted strings. 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.

Every trailing character is the opcode prefix of whatever executes next.

A rule matching the assembled path will never fire

The bytes /!_KEYS_FOR_DECRYPT_! do not 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 do not have.
  • Whether the Windows variant matches. Not yet analyzed.

All in all this was an interesting and fun challenge for me. I hope to make progress on the x64 version of the Linux tool next, then eventually try the final boss that is the Windows version.