Overview

Upon publishing a post about my analysis on a newly discovered Ransomware called “Donex”, I received a hint about a possible vulnerability inside the ransomware by a friend of mine, Josh from InvokeRE, huge thank you to him for making this post possible. The vulnerability we will be covering today is called the Reused key attack, which is present in all stream ciphers. In case you are a victim of this ransomware and need help to recover your files, feel free to contact me on Twitter.

Sample information

SHA256: 0adde4246aaa9fb3964d1d6cf3c29b1b13074015b250eb8e5591339f92e1e3ca

Timestamp: 2024-02-18

Compiler: Microsoft Visual C++

Filetype: Executable

Imported DLLs: Kernel32.dll, User32.dll, Shell32.dll, Advapi32.dll, Mpr.dll, Ws2_32.dll, RstrtMgr.dll

Introduction

In a nutshell, Donex generates a Salsa20 key prior to the file encryption and enumeration and uses this single key for encrypting all of the files. As the name of the Reused key attack vulnerability suggests, reusing a key in a stream cipher can be problematic. In order to recover the files, we need a file that hasn’t been encrypted and it’s encrypted counterpart. For this post I will be using the abstract.h and datetime.h from my python folder as an example. In order to recover your files, you need the plaintext version of a fairly big file and it’s encrypted counterpart. Additionally you need a file you want to recover, this file has to be smaller (on disk) than your plaintext file.

Encryption

First of all, Donex uses a Salsa20 implementation written in C language from GitHub. The function s20_crypt is the function we will focus on. As shown in the picture below, the first argument of this function is the key, the second argument is the key size (note that this argument doesn’t take the key size in bytes but an enum) and the third argument is a pointer to the nonce. The last three arguments won’t be that important for our analysis but they essentially specify the data to be encrypted/decrypted.

Function signature of the Salsa20 routine Figure 1: Function signature of the Salsa20 routine

Here we can see the key size enum where the value 0 represents S20_KEYLEN_256 and the value 1 S20_KEYLEN_128. In the next picture you will see that the third value on the stack, which is the second function argument is the value 1, which indicates the S20_KEYLEN_128 option. Therefore we can safely say, that this ransomware uses a 128-bit Salsa20 key to encrypt the files on the victims system. Note that the first argument on the stack is the return address to the previous function and is not a function parameter.

Keysize enum Figure 2: Available key sizes

Debugging

I’ve opened up the sample in the x64dbg debugger (32-bit version) and put a breakpoint on the file encryption routine for three different files, when we observe the function parameters, we will see that the second value on the stack, which is the key (01119670), is same for all three files. This ransomware also uses the same nonce for all files which consist of 8 null-bytes.

First file Figure 3: First file to be encrypted

Second file Figure 4: Second file to be encrypted

Third file Figure 5: Third file to be encrypted

Decryption using the Reused key attack

After detonating the ransomware in my vm, I’ve put three files and my decryptor inside a folder. The first file is the plaintext version of the encrypted abstract.h. As you might’ve already noticed, I also put a decryptor.exe in this folder, which is the decryption tool I’ve written. The source code of this decryption tool will be at the end of this post.

Files before decryption Figure 6: Files prior to decryption

As I’ve mentioned previously, the file size of the plaintext file must be larger than the file, you want to decrypt, in this case, the size of abstract.h is 32KB.

Filesize of abstract.h Figure 7: Filesize of abstract.h

On the other hand, the file size of the encrypted datetime.h.f58A66B51 is only 11KB.

Filesize of datetime.h.f58A66B51 Figure 8: Filesize of datetime.h.f58A66B51

When we open the datetime.h.f58A66B51 file in notepad++ or a hex editor, we will see, that the file content is unreadable and thus in an encrypted state.

File content of datetime.h.f58A66B51 Figure 9: File content of datetime.h.f58A66B51 before decryption

By typing “decryptor” in a newly opened command prompt, we will see the following prompt. The first argument will be the path or name to the larger encrypted file A and the second argument will be the path/name of the plaintext version of the A. Lastly, the third argument serves as the path/name to the file, that you want to decrypt.

Decryptor Figure 10: Arguments of the decryptor

In our case, we will type the following command into our command prompt:

decryptor abstract.h.f58A66B51 abstract.h datetime.h.f58A66B51

Using the decryptor Figure 11: Using the decryptor

This will prompt us with a message, that our file was successfully decrypted, and when you take a look at the contents in your folder, you will notice, that the campaign ID has been removed from your file name.

Datetime.h decrypted Figure 12: Datetime.h decrypted

Lastly, when we open up the datetime.h file in notepad++, we will see, that our file has been successfully recovered.

File content of datetime.h Figure 13: File content of datetime.h after decryption

Source Code

I’ve compiled the decryptor in Microsoft Visual Studio 2022 in C++14.

#include <Windows.h>
#include <iostream>

typedef unsigned char U8;
typedef unsigned short U16;
typedef unsigned int U32;
typedef unsigned long long U64;

#define RANSOM_EXTENSION ".f58A66B51"
#define HEADER_SIZE 512

/*
	1st argument = ciphertext a file path
	2nd argument = ciphertext b file path
	3rd argument = plaintext a path
*/

void xor_crypt(U8* data, U8* keystream, size_t size_keystream) {
	for (size_t i = 0; i < size_keystream; ++i) {
		data[i] = data[i] ^ keystream[i];
	}
	return;
}

std::string stripExtension(const std::string& filePath) {
	return { filePath, 0, filePath.rfind(RANSOM_EXTENSION) };
}

int main(int argc, char** argv) {
	if (argc < 4) {
		// Print Help Message
		std::cout << 
			"Donex Ransomware decryption using key reuse attack PoC (.f58A66B51 campaign) by https://dissect.ing/\n\n" <<
			"1st argument - ciphertext (A) file path (This file should be larger than ciphertext B)\n2nd argument - plaintext version of file A\n3rd argument - ciphertext (B) file path\n\n";
		return 0;
	}
	// Get arguments
	const std::string cipher_path_a = argv[1];
	const std::string file_to_decrypt_path = argv[2];
	const std::string cipher_path_b = argv[3];

	// Open Handles to Files
	HANDLE hCipherA{ CreateFileA(cipher_path_a.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL)};
	HANDLE hCipherB{ CreateFileA(cipher_path_b.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL) };
	HANDLE hFileToDecrypt{ CreateFileA(file_to_decrypt_path.c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL) };

	// Get file sizes
	DWORD dwFilesizeCiphertextA = GetFileSize(hCipherA, NULL);
	DWORD dwFilesizeCiphertextB = GetFileSize(hCipherB, NULL);
	DWORD dwFilesizeFileToDecrypt = GetFileSize(hFileToDecrypt, NULL);

	// Allocate buffers for storing the file contents
	U8* ciphertext_a = (U8*)malloc(dwFilesizeCiphertextA);
	U8* ciphertext_b = (U8*)malloc(dwFilesizeCiphertextB);
	U8* file = (U8*)malloc(dwFilesizeFileToDecrypt);

	// Read file contents into the previously allocated buffers
	(void)ReadFile(hCipherA, ciphertext_a, dwFilesizeCiphertextA, NULL, 0);
	(void)ReadFile(hCipherB, ciphertext_b, dwFilesizeCiphertextB, NULL, 0);
	(void)ReadFile(hFileToDecrypt, file, dwFilesizeFileToDecrypt, NULL, 0);

	// Decrypt
	xor_crypt(ciphertext_a, ciphertext_b, dwFilesizeCiphertextA);
	xor_crypt(ciphertext_a, file, dwFilesizeFileToDecrypt);

	// Write back to file
	(void)SetFilePointer(hCipherB, 0, 0, FILE_BEGIN);
	(void)WriteFile(hCipherB, ciphertext_a, dwFilesizeCiphertextB-HEADER_SIZE, NULL, 0);
	(void)SetFilePointer(hCipherB, dwFilesizeCiphertextB - HEADER_SIZE, 0, FILE_BEGIN);
	(void)SetEndOfFile(hCipherB);

	// Close Handles to Files
	(void)CloseHandle(hCipherA);
	(void)CloseHandle(hCipherB);
	(void)CloseHandle(hFileToDecrypt);

	// Free the allocated buffers
	free(ciphertext_a);
	free(ciphertext_b);
	free(file);

	// Remove ransomware extension
	const std::string filepath = stripExtension(cipher_path_b);
	BOOL bRenamed = MoveFileA(cipher_path_b.c_str(), filepath.c_str());
	if (bRenamed) std::cout << "Successfully decrypted! :D\n";
	return 0;
}

References

https[:]//twitter.com/JershMagersh - for giving me the hint :D

https[:]//invokere.com/ - Josh’s website

https[:]//en.wikipedia.org/wiki/Stream_cipher_attacks - explanation for the vulnerability

https[:]//bazaar.abuse.ch/sample/0adde4246aaa9fb3964d1d6cf3c29b1b13074015b250eb8e5591339f92e1e3ca/ - The sample used in this post

https[:]//chuongdong.com/reverse%20engineering/2022/09/03/PLAYRansomware/ - inspired me with his beautiful layout :P

https[:]//github.com/alexwebr/salsa20 - The Salsa20 C implementation used by Donex

https[:]//chat.openai.com/ - for fixing my broken english