On April 13th 2020, news broke out on Portuguese media  that Energias de Portugal (EDP), the Portuguese multinational energy giant and one of the largest European operators in energy & wind sectors, had been hit by a highly targeted ransomware attack (later identified as Ragnar Locker ), amid COVID-19 pandemic, while the country had been under state of emergency. The attackers behind the ransomware were, supposedly (although not confirmed), demanding 1580 BTC (9.9 million EUR) by threatening to leak all of the stolen data (10TB, according to the perpetrators themselves). It has since been considered one of its worst cyber attacks.
As such, and as an information security consultancy company based in Porto, Portugal, we have decided to take initiative in investigating the ransomware sample ourselves by getting our hands dirty and going right down to its truth. We were specifically interested in understanding how this ransomware was built, i.e., its technical details, its capabilities and sophistication. The analysis and its end results are therefore presented in this blog post, in a detailed fashion, for all the curious readers who wish to know more about the final (destructive) part of the hack.
One of the very first steps an analyst should do when first interacting with a potentially malicious executable is to perform basic static analysis on the PE, e.g., look at its PE headers, sections, imports, strings, or any other information that can help him get an overall general idea of what the binary might do or contain. In this particular case, when looking at its imports, we can see several windows APIs which are commonly (ab)used by malware in order to hide their deed. This includes (but not limited to) VirtualAlloc*(), LoadLibrary*() and GetProcAddress(). Something weird that stood out when looking at the imports is the existence of several APIs that are only needed for thread synchronization (InitializeCriticalSectionAndSpinCount(), EnterCriticalSection(), LeaveCriticalSection() and DeleteCriticalSection()), yet, there are no imported APIs responsible for creating threads in the first place, e.g., CreateThread().
Hex dumping our target shows several strings, of which some, after looking them up on google, reveals pages related to malware and malware analysis services.
Once a more in depth analysis takes place, it becomes quite clear that the ransomware is, in some way, obfuscated. For example, and for demonstration purposes, the following image displays a function that is called with the string "EV_MMAC_OID_TERMINATE_CONNECTION" as argument, where the string is never actually used for anything and an existing loop is never entered due to the result of the comparison always leaving EFLAGS.ZF unset (opaque predicate).
The thread synchronization APIs mentioned earlier also take part of the obfuscation (essentially junk code), where a loop is concluded after being executed 2000000 times, performing useless arithmetic operations and calling a function which always returns 0 along the way.
The interesting parts, from a malware analysis point of view, will only take place once the ransomware calls VirtualAllocEx() with PAGE_EXECUTE_READWRITE memory permissions (flProtect). The allocation of pages with such memory permissions is highly indicative that something interesting will be written into them that will later be treated as code to be executed, possibly taking part on the unpacking process.
The following image demonstrates the algorithm being used by the ransomware, where it starts decompressing/decrypting and writing shellcode into the new memory area.
When it is finished writing the shellcode, it will call GetModuleHandleW(L"kernel32") in order to obtain the base address of kernel32.dll that is mapped in the current process address space. It will then transfer control-flow into the new RWX memory area containing the newly decrypted shellcode, passing the pointer to the retrieved kernel32.dll base address as argument.
At this point, execution of the shellcode takes place. The following image demonstrates its initial instructions.
As it can be seen from the above image, the shellcode starts by performing a series of MOV r/m8, imm8 instructions that are being used to construct on the stack, one byte at a time, strings representing names of Windows APIs. After it is done placing them on the stack, it will call a subroutine passing, again, the base address of kernel32.dll and the string "GetProcAddress" as argument.
The following image demonstrates the initial instructions of this subroutine.
For anyone that has done enough malware reversing, or simply to anyone familiar with the PE file format, it becomes clear that this function is being used to manually iterate through kernel32.dll's exports in order to dynamically resolve, at runtime, the address of GetProcAddress() so that it can be subsequently used. The giveaway is the fact that first, it gets the address of kernel32.dll's PE header by adding kernel32.dll's base address (DOS Header) with the value stored in the e_lfanew field (at offset 0x3C). Then, it will obtain the address of the Export Table by adding the Relative Virtual Address (RVA) located in pNtHdr->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress (at offset 0x78). After GetProcAddress() is resolved, it will then be used to dynamically resolve the rest of the APIs whose names were constructed previously one byte at a time on the stack.
Then, VirtualAlloc() is called, but this time the memory access permissions do not include execution, allowing only reads and writes (PAGE_READWRITE).
Eventually, a relatively long series of operations are performed, resulting in writes to the newly allocated memory region, where a full PE is decrypted at runtime into it. It is easily recognizable through the MS-DOS MZ header. This particular point is the best time to dump the memory region into disk, as the PE is in its unmapped (raw) format, i.e., how it is stored on disk, versus its mapped (virtual) format, i.e., how it needs to be loaded into memory by the loader for actual program execution.
By dumping the PE to disk, it can be further analyzed through the very same basic static analysis steps in order to get a general idea of what this new binary might have (or do). As we can see, it contains an interesting .keys section and its Time Date Stamp (compilation date) is set to Monday, 06.04.2020 19:57:20 UTC. This date is particularly interesting as it's just a few days prior to the actual initial reports of the attack. Please note, however, that such date can be easily modified.
Proceeding execution, the SizeOfImage (the size of the image, in bytes, including all headers) of the newly decrypted PE is then obtained, via pNtHdr->OptionalHeader.SizeOfImage, as seen by the use of offset 0x3C to get the address of the PE header, and then by adding to that result 0x50. The SizeOfImage will be used as the dwSize argument of the VirtualAlloc() call that follows, with lpAddress being the base address of the currently running process' binary image and memory access permissions set to PAGE_EXECUTE_READWRITE.
After the VirtualAlloc() call, the main binary's image (of the currently running process) is overwritten with 0's, in a loop that ends after executing SizeOfImage (of the new decrypted PE) times.
Then, the new PE's headers are copied into its place, as well as it loads its sections into the correct locations, not caring about their memory permissions. At this point, we can already tell that the ransomware performs self process injection.
By comparing the base address where the new PE was placed against its ImageBase (preferred base address), via pNtHdr->OptionalHeader.ImageBase (as seen by offset 0x34), it can decide whether base relocations need to take place or not. In this case, base relocations do not need to be performed, but there is code inside the shellcode that could do it in case it was needed.
The Import Address Table (IAT) is then fixed up by first loading needed DLLs and then resolving needed imports by the PE. The following image demonstrates this initial process, as seen by accessing the Import Table, via pNtHdr->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress (offset 0x80).
By accessing AddressOfEntryPoint, via pNtHdr->OptionalHeader.AddressOfEntryPoint (offset 0x28), the Original Entry Point (OEP) is then obtained and subsequently called, thus transfering execution to the newly unpacked executable, as it is now ready to be executed.
One of the very first things that is done after execution starts at the new PE's entry point is to call a subroutine that eventually calls GetLocaleInfoW() with LCID LOCALE_SYSTEM_DEFAULT (default locale for the operating system), in order to compare it against a possible set of unicode strings previously constructed on the stack by mov instructions. The constructed unicode strings are:
If the requested locale information matches any of those strings, as seen by the use of lstrcmpiW(), then the current process is terminated via TerminateProcess() with exit code 666.
It then calls GetComputerNameW(), GetUserNameW() and some other function twice with different arguments, the first time with "SOFTWARE\Microsoft\Cryptography" and "MachineGuid", while the second time with "SOFTWARE\Microsoft\Windows NT\CurrentVersion" and "ProductName".
The function simply allocates a page via VirtualAlloc(), opens the provided subkey via RegOpenKeyExW() from the HKEY_LOCAL_MACHINE (HKLM) registry hive and KEY_READ access rights, and then retrieves the data for the provided value name associated with the opened registry key via RegQueryValueExW(). The pointer to the retrieved data (the page returned from the VirtualAlloc() call) is then the return value of this function.
For each of the data obtained via the calls to the APIs (GetComputerNameW() and GetUserNameW()) and the function responsible for retrieving the data associated with the opened registry keys, it will perform a series of operations on them. Specifically, for each character, it will XOR it the value 0xAB01FF3C, add the previous value to the next one, rotate it left 13 bits and subtract the result of the rotate operation with the value before the rotate. This is done so that unique IDs result from the operations, where they are later concatenated.
The result of the previous operations (unique IDs) and their concatenation is so that it will be used as lpName (the name of the event object) passed to CreateEventW(). But first, it checks if argc (argument count) is 1, if it's not, CreateEventW() is skipped entirely. However, if it is 1, a loop is entered where CreateEventW() is called each time and it only breaks out of it if the return value of the CreateEventW() API call differs from 183 (ERROR_ALREADY_EXISTS). Otherwise, the loop is repeated 32768 times, at which point the current process is terminated via TerminateProcess() with exit code 666.
Then, it will enter another loop, executed 17 times, where it tries to open \\.\PHYSICALDRIVE%d (a physical hard drive) via CreateFileW(), where %d is incremented for each iteration in the loop, starting from 0. If the return value from CreateFileW() differs from 0xFFFFFFFF, it will then call DeviceIoControl() on the handle with control code IOCTL_DISK_SET_DISK_ATTRIBUTES, attempting to bring the disk online and allowing write operations (Attributes field in SET_DISK_ATTRIBUTES struct is set to 0, and AttributesMask field set to 0x3). It will also call DeviceIoControl() again, this time with control code IOCTL_DISK_UPDATE_PROPERTIES, invalidating the cached partition table and synchronizing the system view of the specified disk device, since at this point it would have been modified.
For every existing volume without an associated drive letter, it will then attempt to associate it an unused drive letter. This is done by scanning and iterating through existing volumes on the system by making use of the FindFirstVolumeA()/FindNextVolumeA() combination and determining whether a drive letter is already associated with the volume via GetVolumePathNamesForVolumeNameA(). If no drive letter has been associated with the volume, it will then obtain available drive letters via a call to GetLogicalDrives(), where the first unset bit from the returned bitmask (starting from the 4th) can be used.
At this point, the same routine will be called twice, one after the other, with different arguments.
This routine is responsible for generating, via CryptGenRandom(), cryptographically random bytes of length specified as second argument to the routine, storing it at the address specified in the first argument. These random bytes are subsequently modified by a relatively long series of operations.
Another routine will be called, this time called thrice. One of the arguments to the function that is always passed is a pointer to a suspiciously looking string.
It turns out that this function is responsible for decrypting several data stored in the binary's .keys section. The first time the routine is called, it decrypts the Tor client chat ID used to communicate with the perpetrators.
The second time it is called, it is used to decrypt a series of strings, which will later be used as reference for substrings to look for in order to determine what services to stop, as we will see. The strings are:
The third time it is called, it is used to decrypt another series of strings, which will later be used as reference for substrings to look for in order to determine which processes to terminate, as we will also see. The strings are:
After some data has been decrypted, as seen in the previous steps, it will now attempt to establish a connection to the service control manager (via OpenSCManagerA()) of the local computer (lpMachineName is set to NULL) and open the SERVICES_ACTIVE_DATABASE database (lpDatabaseName set to NULL) with the dwDesiredAccess argument set to SC_MANAGER_ALL_ACCESS. The first call to EnumServicesStatusA() should fail, with the last-error code set to ERROR_MORE_DATA, as cbBufSize (the size of the buffer pointed to by the lpServices parameter, in bytes) is set to a small value (36 bytes). If it does fail, then pcbBytesNeeded will receive the number of bytes needed to return the remaining service entries (via the second call to EnumServicesStatusA()), where execution can now continue to the code path that will attempt to stop some services.
For every enumerated service that contains a substring (StrStrIA()) from the possible set of strings existing in the previously decrypted data, it will call a subroutine.
This subroutine is responsible for calling GetTickCount(), opening the existing service via OpenServiceA() with dwDesiredAccess of SERVICE_STOP | SERVICE_QUERY_STATUS | SERVICE_ENUMERATE_DEPENDENTS, look up the status of the service via QueryServiceStatusEx() and:
If its dwCurrentState is SERVICE_STOPPED, it closes the service handle via CloseServiceHandle() and returns.
If its dwCurrentState is SERVICE_STOP_PENDING, then it will enter a loop where it sleeps via Sleep() for either 10000 or 1000 milliseconds, calls QueryServiceStatusEx() again and exists the loop if dwCurrtentStats is SERVICE_STOPPED or if more than 30000 milliseconds have passed (via another call to GetTickCount() and subtracting it its previous value).
If its dwCurrentState is SERVICE_RUNNING, it will then attempt to enumerate its dependent services via EnumDependentServicesA(), open the enumerated dependent services via OpenServiceA() with dwDesiredAccess of SERVICE_STOP | SERVICE_QUERY_STATUS and call ControlService() on them with dwControl of SERVICE_CONTROL_STOP. After the enumerated dependent services are stopped, it will finally call ControlService() on the initial opened service and stop it by using, again, the dwControl of SERVICE_CONTROL_STOP.
After the services are dealt with (stopped), it's now time for some process termination. As in the services case, it loops through all the processes in the system and checks via StrStrIA() if a substring referred in the previous decrypted data is present in its name. It does this by first calling CreateToolHelp32Spanshot() with dwFlags TH32CS_SNAPALL (and th32ProcessID 0), then processes are iterated via the Process32FirstW()/Process32NextW() combination.
If the process name contains any substring as indicated by the decrypted data, then the process is opened via OpenProcess() with dwDesiredAccess of PROCESS_TERMINATE and terminated via TerminateProcess() with exit code 666.
After process termination, another routine is called. This routine first calls GetNativeSystemInfo() in order to check the value of DUMMYUNIONNAME.DUMMYSTRUCTNAME.wProcessorArchitecture stored in the SYSTEM_INFO struct. If wProcessorArchitecture is PROCESSOR_ARCHITECTURE_AMD64 (0x9), then LoadLibraryW(L"kernel32.dll") is called and the address of Wow64EnableWow64FsRedirection() is obtained via a call to GetProcAddress(). This WinAPI is then called with Wow64FsEnableRedirection set to FALSE, thus disabling WOW64 system folder redirection.
When redirection is disabled, two unicode strings are built on the stack by a series of mov instructions. These unicode strings will be used as lpCommandLine for subsequent calls to CreateProcessW(). The executed command lines are:
wmic.exe shadowcopy delete
vssadmin delete shadows /all /quiet
Right after shadow copy deletion, LoadLibraryW(L"kernel32.dll") is called once again and Wow64EnableWow64FsRedirection() is obtained via GetProcAddress(), this time in order to be called with Wow64FsEnableRedirection set to TRUE, thus enabling WOW64 system folder redirection. The routine then returns.
It is now time for some more data decryption from the .keys section. This time, the data that is decrypted is a 2048-bit RSA public key. We will see how it will be used later.
Other data that is decrypted, by another call to the routine, is the final ransom note. Please refer to the following image.
The 2048-bit RSA public key is then converted and its public key information imported via CryptImportPublicKeyInfo() into the provider.
By calling a subroutine twice that calls CryptEncrypt(), the previous two cryptographically random data that were generated by both calls to CryptGenRandom(), which were subsequently modified through a series of operations, will be encrypted with the 2048-bit public key.
Via a call to GetComputerNameW(), and through the same series of operations that were used to generate unique IDs for the CreateEventW() even object name (lpName), an hex encoded ID is generated.
Through concatenation, by making use of lstrcatW(), and a call to SHGetSpecialFolderPathW() with csidl CSIDL_COMMON_DOCUMENTS, the path C:\Users\Public\Documents\RGNR_E354BDB6.txt is built.
Along the way, a block of heap memory allocated via RtlAllocateHeap() is called with HEAP_ZERO_MEMORY as Flags, which initializes it with 0's. For some reason, this memory area will be, again, zeroed out after the call to RtlAllocateHeap().
The Tor client chat ID previously decrypted is then converted to Base64, by making a call to CryptBinaryToStringA(), as seen by the use of dwFlags set to CRYPT_STRING_BASE64.
The previously decrypted specifically targeted ransom note that will be left in the attacked systems is then written via WriteFile() to the C:\Users\Public\Documents\RGNR_E354BDB6.txt path that had been built moments prior, by first opening it via CreateFileW() with dwDesiredAccess of GENERIC_READ | GENERIC_WRITE.
Then, through concatenation, the "RAGNAR SECRET" will be appended to the file, which is simply the Base64 encoded version of the Tor client chat ID.
After the file with the ransom note has been written to, the ransomware will check if argc (argument count) is higher than 1. The ransomware can be executed with "-list" or "-force" command line options. These are simply used to determine how the paths that will be used as base to start file encryption are obtained. The command line option "-list" gets the paths from a file given as argument, while "-force" starts file encryption from the path given as argument. Since the end goal is file encryption, and these command line options were probably used solely during development for testing purposes by the attackers, we will continue examining as if no arguments are given, i.e., argc == 1.
Through the GetLogicalDrives() API call, a bitmask representing the currently available disk drives is obtained. For every available disk drive, as indicated by the set bits, its corresponding drive letter is retrieved by adding 0x41 ('A'). If GetVolumeInformationW() returns successfully (non-zero) on the volume and its drive type (obtained via a call to GetDriveTypeW()) differs from DRIVE_CDROM, then it can proceed using it as a base to start the file encryption process. The currently obtained drive letter is also compared against the drive letter being used in the WindowsDirectory (e.g., C:\Windows) gotten by the call to GetWindowsDirectoryW(), and if they match, an integer being treated as a flag will be set to 1, otherwise it'll continue being 0.
The file containing the ransom note is then copied into this newly obtained path.
After the file is copied, a subroutine will be called with this new path as argument. One of the other arguments to this subroutine is the integer being treated as a flag to indicate whether the drive letter of the current path matches the drive letter being used where the WindowsDirectory is located. This subroutine starts by iterating through all files and directories existing in the path given as argument, via the FindFirstFileW()/FindNextFileW() combination. At first, it only cares about directories and checks if it is not "." or "..". If it's not any of those directories, then it checks whether the integer flag passed as argument is set or not. If it is set, i.e., it's the drive letter being used by WindowsDirectory, then further checks take place.
The checks that take place when the flag passed as argument is set occur so that certain directories are skipped and nothing will be done on them. The directories that are compared against the currently obtained directory are:
- Tor Browser
- Internet Explorer
- Opera Software
- Mozilla Firefox
- All Users
If the obtained directory is not any of those above mentioned directories, then the ransom note file will be copied into this new directory, and the subroutine will be recursively called with this new path. The integer being treated as a flag is still passed as set.
When all files/directories have been iterated and went through the checks, i.e., FindNextFileW() returns NULL, it will then start iterating again through all files/directories. The goal, this time, is to look for files specifically. For every file encountered, it compares its name against a set of possible filenames. These filenames are:
- The ransom note filename
If the currently found file's name matches any of the above filenames, then nothing is done it with and it is skipped.
If the currently found file's name does not match the above list of filenames, then extension checks will also be performed. Specifically, the current file's extension is checked against:
If the extension of the current file's name matches any of the above list of extensions, then nothing is done with it and it is skipped. If it doesn't match, however, the pointer to the file's name will be added into a stack array.
If 64 files in the current directory under examination have been added into the stack array (thus passing all of the above checks), then 64 threads will be created via CreateThread(). Each of the 64 pointers in the stack array are passed as lpParameter and the routine that handles file encryption is passed as lpStartAddress.
If, at the end of examination of the directory, the number of files in the stack array are less than 64, then a thread will be created via CreateThread for each of the files. Each of the pointers in the stack array are passed as lpParameter and the routine that handles file encryption is also passed as lpStartAddress.
In the thread that handles file encryption, it first reads via ReadFile() the 9 last bytes of the file. If the marker string _RAGNAR_ is found, then this file will not be encrypted, as it is already the result of previous encryption, as we will see.
If the marker is not found, a routine is called which performs a series of operations on both cryptographycally random bytes resulted from the calls to CryptGenRandom() (although immediatly later modified by another series of operations). The call to this function is seen below. The result will later be used in the actual file encryption process.
A teaser on these operations is demonstrated in the following image.
Then, the actual routine that encrypts the file is called.
The encryption cipher used is based on add-rotate-xor (ARX) operations, appearing to be a modified version of the Salsa20 stream cipher. The following image demonstrates what very closely resembles it.
When file encryption is complete, both cryptographycally random sequence of bytes that were encrypted by the 2048-bit RSA public key will be appended into the encrypted file. The file marker _RAGNAR_ is also appended.
After everything is written into the new file as part of the encryption process, the file will be moved via MoveFile(), essentially adding it a new extension: .ragnar_E354BDB6
Finally, after everything is complete, the ransomware will end execution by:
- Retrieving the SessionID of the console session (the session that is currently attached to the physical console) via WTSGetActiveConsoleSessionId()
- Opening the current process token via OpenProcessToken() with DesiredAccess of TOKEN_ALL_ACCESS
- Creating a new access token that duplicates it via DuplicateTokenEx with dwDesiredAccess of TOKEN_ALL_ACCESS and TokenType of TokenPrimary
- Setting the console's SessionID on the new duplicated access token via SetTokenInformation()
- Creating a process using the new access token via CreateProcessAsUserW(), starting notepad.exe with the ransom note file as its argument
- Calling ExitProcess(0)
And now for the obligatory ransom note display.
From the thorough analysis of the Ragnar Locker ransomware that left a specifically targeted ransom note to Energias de Portugal, a few things can therefore be concluded. First, it is entirely obvious that no inside or outside world connections are attempted by the ransomware itself, proving that any stolen files must have had to be stolen and exfiltrated from their network before ransomware execution by the attackers. The unpacked executable has a Time Date Stamp (compilation date) of "Monday, 06.04.2020 19:57:20 UTC", which is 7 days earlier than that of actual deployment, possibly hinting that the perpetrators had access into EDP's networks since at least that specific date.
The ransomware does not ship any anti-debugging or anti-VM techniques, nor does it really do much in order to thwart or even slow down analysis from unintended prying eyes. Many of the actions performed by the ransomware would require SYSTEM privileges, even though it does not contain any UAC "bypassing" capabilities (note the double quotes; UAC is not a security boundary). However, since it has been manually executed by the attackers who must have had prior access, such permissions could be easily identified (and possibly obtained) before deployment. It is the actual definition of ransomware, doing no more and no less. If the default locale of the systems where the ransomware is run has a specific set of possible settings, the process immediatly terminates.
In theory, the perpetrators can possess file decryption capabilities, as the cryptographycally secure data used to then derive the symmetric key and nonce are appended to the newly encrypted files, in encrypted form, using the 2048-bit RSA public key that is embedded in the binary (decrypted at runtime only). The ransomware could have most probably been detected either via static analysis or at runtime as it were executing due to its heavy use of seemingly malicious WinAPIs.
We sincerely hope that you have enjoyed our deep dive into the technical side of the final stages of the attack.
Cheers and until next time,
Blaze Information Security
IOC Hashes (SHA256)