A Programmer's View On Conti-Locker
Type - Ransomware
Introduction
I am writing this because a majority of articles summarize how this ransomware works but don't go into much detail on how the code works. I will be looking at the leaked source code of Conti-Locker ransomware and explaining in detail how it's code works using flowcharts and my knowledge on system programming. I hope this also helps future malware writers/ red-teamers make similar prototypes for adversary simulations.
Conti-Locker is a ransomware developed by the Conti Ransomware Gang, a Russian-speaking criminal collective with suspected links with Russian security agencies. Conti Ransomware Gang operated with a Ransomware-as-a-service (Raas) model. A summary on the gang can be found at the forescout website.
In the Raas model, a ransomware developer/group designs and creates a ransomware and lends it to affiliates or "Operators" who use it on organizations in return for a percentage of the profit. This model has become infamous in the recent times due to it's popularity and ease of use. The technical intricacies of developing a ransomware rests on the group whereas the operators just utilize it to ransom organizations. This with addition with social-engineering means the ceiling for conducting cybercriminal activities has dropped down to such a level that even people with bare-minimum technical skills can conduct ransomware activities.
The source code was leaked by an disgruntled affiliate in revenge for the cybercriminals siding with Russia on the invasion of Ukraine on January 25th, 2021.
Encryptor
Setup
The ransomware first sets up a custom structure named "string_" which contains a character array of length 16384 bytes and a doubly linked tail queue which holds "Entries". This structure is then used in another doubly linked tail queue named "string_list_" which takes a "STRING_LIST".

After these structures are created, there is a function named "my_stoi" which is a custom implementation of extracting integers from a string (probably to avoid winAPIs in the IAT). While the implementation is not that computationally efficient (because it multiplies digits by powers of 10 and adds them up), it does the work.

Below that is a "ParseFile" function which supposedly takes in a FilePath and a list of filenames and parses their contents. The function expects the input file to be encoded in an OEM/DOS-style character set and converts each line to a Unicode (wide character) string before adding it to the list. While being a bit rudimentary, it's implementation is actually very clever. Instead of directly giving string literals, it is dynamically hashing the APIs and it's usage of TAILQ ensures that the parsing process is extremely efficient.

The command-line arguments are parsed by the "GetCommandLineArg" function which performs a non-case sensitive comparison of the CLI arguments against a list using plstrcmpiW WinAPI. But the actual handling of the arguments is done by "HandleCommandLine" function. Following are the CLI options of Conti-Locker -
"-h" -> Specifies the HostsPath
"-p" -> Specifies the pathlist
"-m" -> Specifies the encryption mode
"-log" -> Enables Logging
Conti-Locker has 4 types of encryption modes available. We will look into thes soon.
ALL_ENCRYPT (Enabled by default)
LOCAL_ENCRYPT
NETWORK_ENCRYPT
BACKUPS_ENCRYPT
More configuration options are available in the "global_parameters.cpp" file including extensions, mutex names, etc.

Main Function
Below is a simplified flowchart of what the ransomware does in general. We will delve deeper into what it does in detail soon.

As seen in the flowchart, the first thing that the ransomware does is create a system-wide mutex to ensure only one instance of the ransomware runs at a time. The mutex name,
"kjsidugidf99439", is obfuscated using theOBFAmacro to prevent detection based on a known string. Also "pWaitForSingleObject" WinAPI is used to check if a mutex already exists. If it does, it immediately exits.Then it performs dynamic API resolution, which loads WinAPIs during runtime using LoadLibrary and GetProcAddress. This is defined in then namespace of api::InitializeApiModule().
After that it finds and disables function hooks placed by antivirus (AV), Endpoint Detection and Response (EDR), or analysis tools on critical Windows API functions. By unhooking these functions, the ransomware can execute its operations (like file I/O or process manipulation) without being monitored or blocked.
The code then initializes five separate linked lists using the
TAILQ(tail queue) macro. These lists will be used to manage the malware's state and targets:g_WhitelistPids: A list of Process IDs (PIDs) that should not be terminated by theprockillermodule.DriveList: A list of local drives (e.g., C:, D:) to be scanned for files.ShareList: A list of network shares (e.g.,\\SERVER\share) discovered during the network scan.g_PathList: A list of specific file paths provided via the command line to be encrypted.g_HostList: A list of specific network hosts provided via the command line to be targeted.

It then parses CLI arguments and setups various ThreadPools for multi-threading.
Before the encryption process though, it does pre-encryption steps to ensure no are the attack is successful -
locker::DeleteShadowCopies()- It executes a command (likely usingvssadmin.exe delete shadows /all /quiet) to delete all Volume Shadow Copies. This prevents the victim from using the "Previous Versions" feature in Windows to easily restore their encrypted files.process_killer::GetWhiteListProcess(&g_WhitelistPids)- Populates theg_WhitelistPidslist with processes that are critical for system stability or for the ransomware's own operation, ensuring they are not terminated.locker::SetWhiteListProcess(&g_WhitelistPids)- This passes the whitelist to the encryption module so it can avoid encrypting files that are currently in use by these whitelisted processes, preventing system crashes.

Finally, the encryption process is started. As per the provided encryption scope, the ransomware recursively enumerates local, network and backup drives and encrypts them. Multiple thread pools are started at once to encrypt files as soon as possible.
Now that we know what the ransomware does in general, we can delve deeper.
Logging Capabilities

This module is designed to record the malware's operational progress and any errors it encounters for debugging or tracking purposes by the malware authors. The ransomware utilizes a CRITICAL_SECTION to synchronize threads writing to the log file. A logfile named - "CONTI_LOG.txt" is created in the C drive and logs are written there.
The malware's logging namespace has two functions, namely - "Init" and "Write". Init function initializes the critical section, creates the log file with appropriate permissions and makes it "FILE_FLAG_WRITE_THROUGH" i.e it forces the operating system to write data directly to the disk, bypassing the system's write cache. This ensures that log entries are not lost if the system crashes or is abruptly powered off.

The Write function enables the thread pools to write their debug logs to the logfile. It first checks the availability of the log file, locks the critical section (If another thread is already inside this block, this thread will pause and wait until the other thread calls
pLeaveCriticalSection) and writhes the log to the file.The log is written in the format of -> Timestamp: Log contents.

Obfuscation
Before moving on to other parts, we need to quickly see how Conti-Locker obfuscates the strings to prevent static detection. This will make it easier to know how the anti-security measures are used. The "OBFW" macro that we have been seeing in the code up until now is a part of this obfuscation suite.

The engine first generates a random number during compilation. The numbers are not generated when the program runs, but when the compiler is building it. This is achieved through C++ template metaprogramming (TMP). It first creates a starting
seedvalue by parsing the__TIME__preprocessor macro, which ensures the seed changes every second the code is compiled. It then uses this seed in a compile-time implementation of aLinearCongruentialEngine, a classic pseudo-random number algorithm, which recursively calculates a final random integer.The core idea of the obfuscation engine is to obfuscate each byte of a string literal at compile time using random keys, then decrypt it at runtime only when the string value is needed, so that the decrypted string never exists persistently in the programβs binary data. It's flowchart is given below.

It utilizes Extended Euclidean Algorithm at compile time to find the modular multiplicative inverse of an integer modulo for the affine encryption scheme used in this system. In this context, for any encryption parameter
A, its modular inverse modulo 127 is needed during decryption to reverse the affine transformation applied at encryption.The main work is performed by the
MetaBuffertemplate class, which is parameterized by two encryption keys,AandB, and a compile-time sequence of indexes indicating string positions. When aMetaBufferis instantiated with a string and a sequence of indexes corresponding to its length, its constructor encrypts each character of the string at compile time using an affine cipher:E(x) = (A * x + B) % 127. The encrypted form is stored in a buffer. At runtime, calling thedecrypt()method on the buffer will, if not already done, iterate through the buffer and apply the inverse affine decryption formula (D(y) = A_inv * (y - B) % 127, whereA_invis the modular inverse ofA) to recover the original bytes, marking the buffer as decrypted.The
OBFAandOBFWmacros provide a convenient way for obfuscation directly on ANSI and Unicode string literals, respectively. Macros such as_TSTR,_STR, and_WCSallow for portability between character set types and conditionally switch the method of string handling based on build configuration.Using these macros essentially means that strings are encrypted at compile-time and decrypted at runtime. I found this extremely clever and I admire the creativity that went into this obfuscation engine.
Process Killer
This is one of the modules that is present in the ransomware but isn't used that much. The Process killer namespace has 2 classes - GetWhiteListProcess and KillAll. The latter has been commented out and is out of scope, so we will be mainly talking about the first.

Before the function logic begins, the code defines the necessary data structures for managing the whitelist. The
PIDstruct represents a single node in this list, containing aDWORDto hold the numericdwProcessIdand aTAILQ_ENTRYmacro that embeds the necessary forward and backward pointers for list traversal. ThePID_LISTtype defines the head of this list, which acts as the main handle for the entire collection. The functionGetWhiteListProcessaccepts a pointer to one of these lists (PPID_LIST) as an__outparameter, indicating that the function's primary job is to populate this list, which was created and initialized elsewhere in the program.

The GetWhiteListProcess function identifies the process ID (PID) of the Windows Explorer shell (
explorer.exe) and add it to a dynamically constructed list. This function's purpose is terminate theexplorer.exeservice to ensure it doesn't hold files open (like databases, office applications, or backup agents) to ensure they can be encrypted.

The function's core operation begins by leveraging the Windows Tool Help Library to enumerate all processes currently running on the system. This is initiated with the
CreateToolhelp32SnapshotAPI call, using theTH32CS_SNAPPROCESSflag to specify that it needs a point-in-time snapshot of all active processes.It then prepares a
PROCESSENTRY32Wstructure, which will serve as a container for the information of each process retrieved from the snapshot. Finally with the setup complete,Process32FirstWis called to retrieve the first process from the snapshot, priming the main loop.

This loop iterates over all the snapshotted process until it stumbles upon
"explorer.exe". Most importantly, the target string"explorer.exe"is not present as a plaintext literal. Instead, it is wrapped in theOBFWmacro, which invokes the advanced compile-time string obfuscation engine.When the loop identifies the
explorer.exeprocess, it proceeds to add its PID to the whitelist. It first allocates memory for a newPIDlist node usingm_malloc. The process ID is copied from thepe32.th32ProcessIDfield into thedwProcessIdmember of the newly allocated node. Finally, theTAILQ_INSERT_TAILmacro is called. This function takes the list head (PidList), the new node (Pid), and the name of the entry member (Entries) and safely and efficiently appends the new node to the very end of the whitelist. This process is repeated for every instance ofexplorer.exefound, although typically there is only one.
FileSystem Scanner
Conti-Locker utilizes it's
FileSystemnamespace to scan the local disks for data and files to encrypt. Similarly to before modules, it creates adrive_infostructure for this namespace. This structure contains a wstring value - RootPath , which supposedly stores the file path of each scanned file and a TAILQ_ENTRY macro.


The
EnumirateDrivesfunction identifies all logical drives present on the system (e.g.,C:\,D:\, removable drives) and compiles them into a linked list for later processing. This serves as the starting point for the local encryption phase of the ransomware.It initializes a
TAILQ(a type of linked list) namedDriveList, which will store the root paths of the discovered drives and makes an initial call to the Windows API functionpGetLogicalDriveStringsWwith aNULLbuffer. This is a standard technique to query the API for the required buffer size to hold all the drive strings.Then it allocates a memory buffer and calls
pGetLogicalDriveStringsWa second time, now with the allocated buffer, which the API fills with a sequence of null-terminated strings (e.g.,"C:\","D:\","E:\"), followed by a final extra null terminator to mark the end of the entire list.After that, it enters a while loop to parse the string buffer. The loop iterates through each null-terminated string in the buffer and for each drive string found (e.g.,
"C:\"), it creates aDRIVE_INFOstructure, stores the drive's root path in the structure and finally usesTAILQ_INSERT_TAILto add thisDRIVE_INFOnode to the end of theDriveList.

Optionally if the logging function is enabled, it writes a summary message stating the total number of drives found (e.g., "Found 3 drives:") and the path of each individual drive found.

Before going to the next major function, we need to go over some minor utilities first. The utility
MakeSearchMasktakes a directory path and creates a search mask suitable forFindFirstFileW. It correctly appends\*to the path, ensuring it handles paths that do or do not end with a backslash.

The
MakePathutility safely combines a directory path and a filename into a full path, adding a backslash only if needed.

Next, the
CheckDirectoryutility checks if a given directory name contains any substrings from a hardcoded blacklist and theCheckFilenameutility performs blacklisting for files. The hardcoded blacklist is given in the picture below.

The
DropInstructionfunction is responsible for creating the ransom note file (R3ADM3.txt) in a given directory. It callsglobal::GetDecryptNoteto get the content of the note and writes it to the file usingpCreateFileWandpWriteFile.The main
SearchFilesfunction aggregates everything to execute a comprehensive, non-recursive file search using a breadth-first search (BFS) algorithm. It initializes aTAILQto act as a queue for directories to visit, starting with the providedStartDirectory. The mainwhileloop continues as long as this queue is not empty, dequeuing one directory at a time for processing.For each directory, it immediately calls
DropInstructionto plant the ransom note. It then usespFindFirstFileWandpFindNextFileWto enumerate every file and subdirectory within it. If the item is a directory and not on theCheckDirectoryblacklist, it is added to the end of the queue for future processing. If the item is a file and it passes theCheckFilenameblacklist checks, it is deemed a target.Instead of encrypting the file directly, this function acts as a "producer" in a producer-consumer pattern; it submits the full file path as a task to a designated thread pool via
threadpool::PutTask. This architecture allows the file discovery process to run in parallel with the CPU-intensive encryption process, maximizing throughput. The function even includes a back-pressure mechanism, temporarily suspending itself if the task queue grows too large, which prevents excessive memory consumption.Finally the
StartLocalSearchfunction serves as the high-level entry point that initiates the entire local filesystem attack. It is designed to be executed in its own thread, created by a function likeCreateThread.The function receives a single argument: a pointer to the
DRIVE_LISTthat was populated by theEnumirateDrivesfunction. It then iterates through this list using aTAILQ_FOREACHloop. For each drive it finds in the list, it logs the action for debugging purposes and then calls the mainSearchFilesfunction, passing the drive's root path as the starting point and specifying theLOCAL_THREADPOOLas the target for all encryption tasks generated.
Network Scanner
After scanning the local filesystem, Conti-Locker moves on to the network if given the option. In a nutshell, this module enumerates SMB shares, asynchronously connects to them and adds them to a
HostList.

The module begins by defining the core data structures and global variables that manage the state of the entire scanning operation. These structures are built around
TAILQqueues. The structures are -SUBNET_INFOandSUBNET_LIST: This list is used to store the base addresses of all local subnets that need to be scanned (e.g., 192.168.1.0, 10.0.0.0). The scanner will generate all possible host IPs within these subnets.HOST_INFOandHOST_LIST: This is a thread-safe work queue. Once the scanner confirms that a specific IP address is alive and has the SMB port (445) open, aHOST_INFOstructure containing that IP is added to this list. A separate worker thread will consume hosts from this list to perform the next stage of the attack.CONNECT_CONTEXTandCONNECTION_LIST: This is the most complex structure, designed specifically for the asynchronous IOCP model. EachCONNECT_CONTEXTholds all the state for a single connection attempt to a single IP address. This includes theOVERLAPPEDstructure required by IOCP, theSOCKETfor the connection, the target IP address (dwAddres), and aStateflag (CONNECTED,CONNECTING,NOT_CONNECTED). TheCONNECTION_LISTholds all the connection contexts for a single batch of IP addresses being scanned simultaneously.

Before the scanning can begin, Conti-Locker gathers information about its environment and loads necessary functions. The functions are -
GetConnectEX: This function dynamically loads theConnectExfunction pointer.ConnectExis a powerful Microsoft-specific Winsock extension that can initiate a connection and wait for its completion asynchronously. To get a pointer to it, the code must create a dummy socket and use theWSAIoctlfunction with theSIO_GET_EXTENSION_FUNCTION_POINTERcommand.GetSubnets: It queries the system's ARP table (the mapping of IP addresses to physical MAC addresses on the local network) by callingGetIpNetTable. It then iterates through every entry in this table, intelligently filtering for IPs that belong to private network ranges. For each valid private IP it finds, it extracts the subnet (by taking the first three bytes of the IP address, e.g., 192.168.1) and adds it to theSubnetList, ensuring that each unique subnet is only added once. This effectively tells the malware which network segments to target for its port scan.EnumShares: Once a live host has been identified, this function is called to enumerate its available file shares. It uses theNetShareEnumWindows API function to query the remote machine for a list of its shares. It specifically filters for disk shares (STYPE_DISKTREE) and special or temporary shares, while explicitly blacklisting the administrativeADMIN$share to avoid unnecessary noise or permission errors. For each valid, accessible share found, it constructs the full UNC path (e.g.,\\192.168.1.10\Public), logs the discovery, and adds the path to aShareListfor subsequent processing by the file encryption engine.

The actual scanning happens in the
PortScanHandlerand it's helper functions. This is a dedicated worker thread that runs an event loop centered onGetQueuedCompletionStatusand blocks until an asynchronous I/O operation completes and is posted to theg_IocpHandle.This function handles three types of events -
START_COMPLETION_KEY: This message kicks off a new batch scan. It callsCreateHostTableto prepare connection contexts for an entire subnet.CONNECT_COMPLETION_KEY: This signals that aConnectExattempt has finished. If the connection was successful, it callsAddHostto place the live IP onto the sharedg_HostList. It also decrements theg_ActiveOperationscounter.TIMER_COMPLETION_KEY: This is a timeout mechanism. After a batch scan is initiated, a 30-second timer is set. If the timer fires and there are still active operations (g_ActiveOperations > 0), it means some connections are stuck. The code then cancels these hung operations usingCancelIoto ensure the scanner never gets permanently stuck.
The helper functions of
PortScanHandlerare ->CreateHostTable (This function prepares a batch of hosts for scanning by generating all possible IP addresses),ScanHosts(Once the host table is created, this function iterates through every connection context and fires off an asynchronousg_ConnectExcall targeting port 445 on each IP) andAddHost(a thread-safe function that adds a confirmed live host to theg_HostList, uses theg_CriticalSectionto prevent race conditions and checks against the local machine's own IP addresses to avoid the malware attacking itself).The Host and Share Handler function (
HostHandler) runs in a separate worker thread and acts as the "consumer" to the port scanner's "producer." Its job is to take the live hosts discovered by the scanner and perform the actual attack.

The
HostHandlerruns in an infinite loop, continuously checking theg_HostList. If the list is empty, it sleeps for a second and checks again. When a host appears in the list, the thread wakes up, locks the critical section, removes theHOST_INFOfrom the queue, and unlocks the critical section.It then calls
network_scanner::EnumSharesto get a list of all shared folders on that host. After finding the shares, it iterates through each one and immediately hands off the UNC path tofilesystem::SearchFiles, specifyingNETWORK_THREADPOOLas the destination for encryption tasks. Lastly if it dequeues a host with the specialSTOP_MARKERaddress, it knows the scan is complete, and it exits its loop.The main entry point of this module is the
StartScanfunction. It initializes and coordinates the entire network scanning operation.
Anti-Hooking
Now that everything been enumerated and the malware knows what to do, it can start it's encryption process. But just before the encryption process, it invokes to anti-hooking module to remove function hooks from critical dlls. The
removeHooksfunction contains the core logic and orchestrates the entire process of finding and removing API hooks from a specified loaded module (e.g.,ntdll.dllorkernel32.dll).The core strategy is to get a clean, unmodified copy of the target DLL to use as a reference. The execution flow is as follows -> It calls
GetModuleFileNameto retrieve the full file path of the target module handle (hmodule) on the disk and uses the dynamically resolvedCreateFileto open this file with read-only access. It then usesCreateFileMappingto create a file-mapping object from the file handle. Finally, it callsMapViewOfFile. This maps the entire contents of the DLL file into the current process's memory as a read-only block (originDll).With the clean DLL mapped into memory, the code now needs to find the location of every function it exports. It does this by manually parsing the PE file structure of the
originDllbuffer.After all this, it iterates over function, performing a quick check on the first byte of the in-memory function (
funcHooked) to look for theJMPinstruction. Next, it performs a more definitive check by comparing the first few bytes of the pristine function code (funcAddr) with the first few bytes of the in-memory code (funcHooked). If they do not match, thefuncIsHookedflag is set to true.If a hook is detected, the code proceeds to remove it. It does that in the following way -
It calls
VirtualProtecton the memory region of the hooked function (funcHooked) to change its memory permissions from the typical read-execute to read-write-execute. This is a necessary step, as executable code sections are normally not writable.It then uses
CopyMemoryto copy the first 10 bytes from the unhooked function (funcAddr) over the top of the hooked function (funcHooked). This overwrites theJMPinstruction and any other patched bytes, effectively restoring the function to its original, untampered state.Finally, it calls
VirtualProtecta second time to revert the memory permissions back to their original state.
Encryption Algorithm
Finally we come to the encryption process. Conti-Locker utilizes ChaCha20 for it's encryption process. The encryption module is two-fold. One is the actual encryption using ChaCha20 and the other is creation of thread pools to do the encryption parallelly. Let's talk about the thread pools first. It's flowchart is given below.

The system is designed to manage three distinct pools, identified by the
THREADPOOLSenum:LOCAL_THREADPOOL,NETWORK_THREADPOOL, andBACKUPS_THREADPOOL. This separation allows the malware to allocate different numbers of threads and potentially different priorities for encrypting local files versus files on network shares or backups.The central
THREADPOOL_INFOstructure holds all the state for a single pool, including an array of thread handles (hThreads), counters for threads and tasks, aCRITICAL_SECTIONfor thread-safe queue access, the task list itself (TaskList, aTAILQ), and components for managing workflow, such as an event handle (hQueueEvent) and a flag for back-pressure (IsWaiting).The
Createfunction allocates the necessary resources for a specific pool, including the array for thread handles and the synchronization objects. TheStartfunction is then called to launch the worker threads; it iterates the specified number of times and callsCreateThread, passing theThreadHandlerfunction as the entry point for each new thread.

The
PutTaskfunction serves as the interface for producer threads to add work to the queue. It is a thread-safe operation that locks the critical section, creates aTASK_INFOstruct containing the filename, and adds it to the tail of the list. TheSuspendThreadfunction, called by the producer, waits on an event object, effectively blocking the producer until the queue has been drained to a manageable level by the consumer threads. Finally, theWaitfunction provides a graceful shutdown mechanism. It sends a specialSTOP_MARKERtask to each worker thread and then callsWaitForMultipleObjects, blocking until all threads in the pool have finished their current work.

The main showrunner of the thread pool part is the
ThreadHandlerfunction. Each thread created by thethreadpool::Startfunction begins execution here and remains in this function for its entire lifetime. At the outset, each thread performs a one-time initialization to prepare for its encryption duties. It allocates a large, 5MB memory buffer (BufferSize) for efficient file I/O operations. It also establishes a persistent cryptographic context by callingGetCryptoProviderto acquire a handle to the Microsoft CryptoAPI provider and then usespCryptImportKeyto import the global, hardcoded attacker's RSA public key (g_PublicKey). This setup is done once per thread.

After initialization, the thread enters its main
while (TRUE)loop, where it continuously attempts to dequeue and process tasks. It first locks the pool's critical section, then checks the head of theTaskList. If a task is available, it is removed from the queue, and the task counter is decremented. If the producer thread was suspended (IsWaitingis true) and the task count has now dropped to half of the maximum, the worker thread callsSetEventonhQueueEventto signal the waiting producer thread that it can resume adding new tasks.After unlocking the critical section, the worker processes the task. It checks if the filename is the special
STOP_MARKER, and if so, it exits the loop to terminate. Otherwise, it prepares alocker::FILE_INFOstructure and passes it, along with the pre-initialized crypto provider, RSA key handle, and I/O buffer, to the mainlocker::Encryptfunction.Upon successful encryption, the worker calls
locker::ChangeFileNameto append the ransomware's extension to the file, finalizing the attack on that specific file. After processing is complete, it securely cleans up any handles or keys associated with the file and deletes theTASK_INFOobject before looping back to get the next task. Now onto the encryptor.

Before encryption, Crypto-Locker executes the
locker::DeleteShadowCopiesfunction. This function uses the Windows Management Instrumentation (WMI) framework via COM interfaces to systematically eradicate all Volume Shadow Copies (VSS), which are Windows' built-in file versioning and system restore points. By doing so, it eliminates the victim's easiest and most immediate path to recovery.The function then initializes the COM library for multi-threaded operation, sets appropriate security levels, and connects to the
ROOT\CIMV2WMI namespace. It also sets the__ProviderArchitecturecontext to ensure it interacts with the native 64-bit WMI provider. It then executes a WQL query (SELECT * FROM Win32_ShadowCopy) to enumerate all existing shadow copies. For each one found, it extracts its unique ID and programmatically constructs a command line to delete it using theWMIC.exeutility, a method that is both powerful and reliable.

Along with the deletion of shadow copies, Conti-locker runs the
KillFileOwnerfunction in parallel. This function uses the Windows Restart Manager API to restart the applications holding a lock on system files. It starts a Restart Manager session, registers the locked file path, and retrieves a list of all processes using it. If the process is whitelisted, the function aborts to avoid system instability or self-termination. If the locking processes are not on the whitelist, it callsRmShutdownwith theRmForceShutdownflag, forcibly terminating them.Two helper functions,
CheckForDataBasesandCheckForVirtualMachines, are used for for the hybrid encryption scheme. Each contains an extensive, hardcoded list of file extensions commonly associated with databases (.sql,.mdb,.mdf,.dbf, etc.) and virtual machine disks (.vmdk,.vhd,.vdi,.qcow2, etc.). These functions perform a quick, case-insensitive check on a file's name to determine if it belongs to one of these.

Conti-Locker utilizes ChaCha20 along with RSA for the encryption process. This is implemented in the
GenKeyfunction. For every single file to be encrypted, a unique 32-byte symmetric key and an 8-byte nonce (IV) are generated using theCryptGenRandomfunction. This key/IV pair is used to initialize a ChaCha20 cipher context (ECRYPT_ctx), which will perform the fast, bulk encryption of the file's content. This unique ChaCha20 key and IV are then immediately encrypted using a hardcoded, master RSA public key that was imported once per worker thread. The result of this RSA operation is a 512-byte encrypted key blob.

After successfully generating the cryptographic keys and opening the target file (using
KillFileOwnerif necessary), it enters a decision tree to select one of three encryption modes based on the file's type and size.Files identified as high-value databases are subjected to
EncryptFull, which overwrites every single byte of the file with encrypted data.Virtual machine disks are targeted with a specific
EncryptPartlymode that encrypts three 7% chunks of the file.For general files, the strategy is size-dependent: small files (under 1MB) are fully encrypted; medium files (1MB to 5MB) are subjected to
EncryptHeader, which only encrypts the first megabyte which makes them unreadable to most programs; and large files (over 5MB) are encrypted with an intermittentEncryptPartlymode that encrypts five 10% chunks.Once the file content has been encrypted, the
WriteEncryptInfofunction is called. This function appends a metadata footer to the end of the now-encrypted file. This footer contains the 512-byte RSA-encrypted ChaCha20 key blob, the original 64-bit file size, and a byte indicating which encryption mode was used. This information is essential for the decryption tool to properly reverse the process.With the footer successfully written, the
ChangeFileNamefunction renames the file by appending the ransomware's custom extension. Finally, theCloseFilefunction performs a secure cleanup.
Decryptor
Majority of the modules of the decryptor are the same as the encryptor, so I will be skipping them. I will however be analyzing the main core decryption module of the decryptor. The flowchart of the decryption module is given below.

The decryptor mirrors the locker's logic with precision, reading the metadata footer from each encrypted file, decrypting the per-file symmetric key using the attacker's master private key, and then applying the correct decryption algorithm based on the mode used during encryption.
The central data structure is
decryptor::FILE_INFO, which is designed to hold all the state required to decrypt a single file. It contains fields for the filename, a handle to the file, the original and encrypted file sizes, the ChaCha20 key and IV, and theEncryptModeandDataPercentvalues that will be read from the encrypted file's metadata.

The core function
decryptor::Decryptfirst callsOpenFileDecrypt. This helper function opens the target file and then immediately callsReadEncryptInfo.ReadEncryptInfoseeks to a position 534 bytes from the end of the file, which is where the locker engine wrote the metadata footer.It reads this 534-byte block, which contains the 512-byte RSA-encrypted ChaCha20 key/IV, the 1-byte encryption mode, the 1-byte data percentage, and the 8-byte original file size. This information is parsed and stored in the
FileInfostruct.The function then calls
CryptDecrypt, providing it with the attacker's master private key (PrivateKey). It uses this private key to decrypt the 512-byteEncryptedKeyblob that was just read from the file's footer. This recovers the original, unique 32-byte ChaCha20 key and 8-byte IV that were used to encrypt this specific file.With the plaintext ChaCha20 key and IV now available, the function calls
ECRYPT_keysetupandECRYPT_ivsetupto initialize the ChaCha20 stream cipher context (FileInfo->CryptCtx).

Now according to the decryption mode, it calls one of the below three functions -
DecryptFull: This function reads the entire file from beginning to end in large chunks (up to 5MB at a time). For each chunk, it callsECRYPT_decrypt_bytesto decrypt the data in-place in the buffer. It then seeks the file pointer back to the beginning of the chunk it just read and overwrites the encrypted data with the newly decrypted plaintext using theWriteDecryptedDatahelper function. This process continues until the entire file has been read, decrypted, and rewritten.DecryptHeader: This function mirrors theEncryptHeaderlogic. It decrypts only the first 1,048,576 bytes (1MB) of the file, leaving the rest untouched. It reads, decrypts, and rewrites the data in the same manner asDecryptFullbut stops after reaching the 1MB limit.DecryptPartly: It uses aswitchstatement based on theDataPercentvalue (either 20 or 50) read from the file's metadata to calculate the exact size of the encrypted chunks (PartSize) and the size of the unencrypted gaps (StepSize). It then enters a loop, performing a series of seeks and partial decryptions. For example, for the 50% mode, it will decrypt a 10% chunk, seek forward over the next 10% unencrypted gap, decrypt the next 10% chunk, and so on, until all five encrypted parts have been restored.

After the content has been decrypted, the function seeks back to the position 534 bytes from the end of the file and calls
SetEndOfFile. This truncates the file, removing the metadata footer and restoring the file to its exact original size. The file is renamed back to it's original filename usingdecryptor::ChangeFileName.In the end of the source code, there is a commented out function that handles the decryption process asynchronously. This means that the malware authors likely had plans to integrate the IOCP model (discussed in the network scanner module) into the decryptor.
Conclusion
The ransomware's design is a case study in modern malware development. The use of multiple thread pools in a producer-consumer model for both filesystem scanning and encryption helps cripple a system as rapidly as possible. This is complemented by a pretty good encryption strategy that uses a hybrid ChaCha20 and RSA scheme, intelligently adapting its method to fully, partially, or header-only based on file type and size to maximize damage. Furthermore, the deletion of Volume Shadow Copies ensures that once infected, the victim HAS to pay the ransomware gang.
As stated in the introduction, understanding these intricate mechanisms is invaluable. For security researchers and blue teams, this code provides a clear blueprint of the advanced threats that modern defenses must counter. For red-teamers and those involved in adversary simulation, it serves as an inspiration in building effective and evasive tools.
Last updated